Unverified Commit 5ffdbeb6 authored by LiangLiu's avatar LiangLiu Committed by GitHub
Browse files

deploy update (#355)

1、frontend vue+vite
2、share task & template
3、x264 rtc stream push
parent 39683e24
<script setup>
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { watch, onMounted } from 'vue'
// Props
const props = defineProps({
query: {
type: Object,
default: () => ({})
},
templateId: {
type: String,
default: null
}
})
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
import {
goToInspirationPage,
getVisibleInspirationPages,
getTemplateFileUrl,
handleThumbnailError,
inspirationSearchQuery,
selectedInspirationCategory,
inspirationItems,
InspirationCategories,
selectInspirationCategory,
handleInspirationSearch,
inspirationPaginationInfo,
inspirationCurrentPage,
previewTemplateDetail,
useTemplate,
applyTemplateImage,
applyTemplateAudio,
playVideo,
pauseVideo,
toggleVideoPlay,
onVideoLoaded,
onVideoError,
onVideoEnded,
openTemplateFromRoute,
copyShareLink
} from '../utils/other'
// 监听模板详情路由
watch(() => route.params.templateId, (newTemplateId) => {
if (newTemplateId && route.name === 'TemplateDetail') {
openTemplateFromRoute(newTemplateId)
}
}, { immediate: true })
// 路由监听和URL同步
watch(() => route.query, (newQuery) => {
// 同步URL参数到组件状态
if (newQuery.search) {
inspirationSearchQuery.value = newQuery.search
}
if (newQuery.category) {
selectedInspirationCategory.value = newQuery.category
}
if (newQuery.page) {
const page = parseInt(newQuery.page)
if (page > 0 && page !== inspirationCurrentPage.value) {
goToInspirationPage(page)
}
}
}, { immediate: true })
// 监听组件状态变化,同步到URL
watch([inspirationSearchQuery, selectedInspirationCategory, inspirationCurrentPage], () => {
const query = {}
if (inspirationSearchQuery.value) {
query.search = inspirationSearchQuery.value
}
if (selectedInspirationCategory.value && selectedInspirationCategory.value !== 'all') {
query.category = selectedInspirationCategory.value
}
if (inspirationCurrentPage.value > 1) {
query.page = inspirationCurrentPage.value.toString()
}
// 更新URL但不触发路由监听
router.replace({ query })
})
// 组件挂载时初始化
onMounted(() => {
// 确保URL参数正确同步
const query = route.query
if (query.search) {
inspirationSearchQuery.value = query.search
}
if (query.category) {
selectedInspirationCategory.value = query.category
}
if (query.page) {
const page = parseInt(query.page)
if (page > 0) {
goToInspirationPage(page)
}
}
})
</script>
<template>
<!-- 灵感广场区域 -->
<div class="flex-1 flex flex-col min-h-0 mobile-content">
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto p-6 content-area main-scrollbar">
<!-- 灵感广场功能区 -->
<div class="max-w-4xl mx-auto" id="inspiration-gallery">
<!-- 固定功能区 -->
<div class="flex-shrink-0 p-1">
<!-- 标题区域 -->
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('inspirationGallery') }}</h1>
<p class="text-gray-400">{{ t('discoverCreativity') }}</p>
</div>
<!-- 搜索和筛选区域 -->
<div class="flex flex-col md:flex-row gap-4 mb-6">
<!-- 搜索框 -->
<div class="relative flex-1">
<i
class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none z-10"></i>
<input v-model="inspirationSearchQuery"
@keyup.enter="handleInspirationSearch"
@input="handleInspirationSearch"
class="w-full bg-dark-light border border-laser-purple/30 rounded-lg py-3 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-laser-purple/50 transition-all focus:border-laser focus:shadow-laser"
:placeholder="t('searchInspiration')" type="text" />
</div>
<!-- 分类筛选 -->
<div class="flex gap-2 flex-wrap">
<button v-for="category in InspirationCategories" :key="category"
@click="selectInspirationCategory(category)"
:class="selectedInspirationCategory === category
? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'"
class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ category }}
</button>
</div>
</div>
<!-- 灵感广场分页组件 -->
<div v-if="inspirationPaginationInfo">
<div class="flex items-center justify-between text-xs text-gray-400">
<div class="flex items-center space-x-1 text-gray-500">
<span>{{ inspirationPaginationInfo.total }} {{ t('records') }}</span>
</div>
</div>
<div v-if="inspirationPaginationInfo.total_pages > 1" class="flex justify-center">
<nav class="isolate inline-flex -space-x-px rounded-md" aria-label="Pagination">
<!-- 上一页按钮 -->
<button @click="goToInspirationPage(inspirationCurrentPage - 1)"
:disabled="inspirationCurrentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': inspirationCurrentPage <= 1 }"
:title="t('previousPage')">
<span class="sr-only">{{ t('previousPage') }}</span>
<i class="fas fa-chevron-left text-sm" aria-hidden="true"></i>
</button>
<!-- 页码按钮 -->
<template v-for="page in getVisibleInspirationPages()" :key="page">
<button v-if="page !== '...'" @click="goToInspirationPage(page)"
:class="[
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
page === inspirationCurrentPage
? 'z-10 text-white focus-visible:outline-2 focus-visible:outline-offset-2 bg-laser-purple focus-visible:outline-laser-purple'
: 'text-gray-200 inset-ring inset-ring-gray-700 hover:bg-white/5'
]"
:aria-current="page === inspirationCurrentPage ? 'page' : undefined">
{{ page }}
</button>
<span v-else class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-400 inset-ring inset-ring-gray-700 focus:outline-offset-0">...</span>
</template>
<!-- 下一页按钮 -->
<button @click="goToInspirationPage(inspirationCurrentPage + 1)"
:disabled="inspirationCurrentPage >= inspirationPaginationInfo.total_pages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': inspirationCurrentPage >= inspirationPaginationInfo.total_pages }"
:title="t('nextPage')">
<span class="sr-only">{{ t('nextPage') }}</span>
<i class="fas fa-chevron-right text-sm" aria-hidden="true"></i>
</button>
</nav>
</div>
</div>
<!-- 灵感内容网格 -->
<div class="flex-1 overflow-y-auto main-scrollbar min-h-0 p-4"
style="overflow-x: visible;">
<div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-3">
<!-- 灵感卡片 -->
<div v-for="item in inspirationItems" :key="item.task_id"
class="break-inside-avoid mb-3 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20">
<!-- 视频缩略图区域 -->
<div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="previewTemplateDetail(item)"
:title="t('viewTemplateDetail')">
<!-- 视频预览 -->
<video v-if="item?.outputs?.output_video"
:src="getTemplateFileUrl(item.outputs.output_video,'videos')"
:poster="getTemplateFileUrl(item.inputs.input_image,'images')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 图片缩略图 -->
<img v-else
:src="getTemplateFileUrl(item.inputs.input_image,'images')"
:alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" />
<!-- 移动端播放按钮 -->
<button v-if="item?.outputs?.output_video"
@click.stop="toggleVideoPlay($event)"
class="md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 悬浮操作按钮(下方居中,仅桌面端) -->
<div
class="hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full">
<div class="flex space-x-2 pointer-events-auto">
<button @click.stop="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyImage')">
<i class="fas fa-image text-sm"></i>
</button>
<button @click.stop="applyTemplateAudio(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyAudio')">
<i class="fas fa-music text-sm"></i>
</button>
<button @click.stop="useTemplate(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('useTemplate')">
<i class="fas fa-clone text-sm"></i>
</button>
<button @click.stop="copyShareLink(item.task_id, 'template')"
class="w-10 h-10 rounded-full bg-blue-500 backdrop-blur-sm flex items-center justify-center text-white hover:bg-blue-600 transition-colors"
:title="t('shareTemplate')">
<i class="fas fa-share-alt text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { switchToCreateView, switchToProjectsView, switchToInspirationView } from '../utils/other'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<!-- 左侧功能区 -->
<div class="relative w-20 pl-5 flex flex-col z-10">
<!-- 功能导航 -->
<div class="p-2 flex flex-col justify-center h-full mobile-nav-buttons" style="margin-top: -10vh;">
<nav class="lg:space-y-3 md:space-y-3 sm:space-x-3 flex flex-col">
<!-- 生成视频功能 -->
<div
@click="switchToCreateView"
class="w-18 h-18 flex items-center justify-center rounded-lg transition-all duration-300
font-medium text-sm border border-transparent hover:scale-105 hover:shadow-lg
hover:shadow-laser-purple/20 mobile-nav-btn"
:class="$route.path === '/generate'
? 'bg-laser-purple/40 text-white border-laser-purple/40 shadow-lg shadow-laser-purple/20'
: 'text-gray-400 hover:text-white hover:bg-dark-light hover:border-laser-purple/30'"
:title="t('generateVideo')">
<i class="fi fi-sr-add text-3xl transition-transform duration-300 group-hover:animate-pulse"></i>
</div>
<!-- 我的项目功能 -->
<div
@click="switchToProjectsView"
class="w-18 h-18 flex items-center justify-center rounded-lg transition-all duration-300
font-medium text-sm border border-transparent hover:scale-105 hover:shadow-lg
hover:shadow-laser-purple/20 mobile-nav-btn"
:class="$route.path === '/projects'
? 'bg-laser-purple/40 text-white border-laser-purple/40 shadow-lg shadow-laser-purple/20'
: 'text-gray-400 hover:text-white hover:bg-dark-light hover:border-laser-purple/30'"
:title="t('myProjects')">
<i class="fi fi-br-house-chimney-heart text-3xl transition-transform duration-300 group-hover:animate-pulse"></i>
</div>
<!-- 灵感广场功能 -->
<div
@click="switchToInspirationView"
class="w-18 h-18 flex items-center justify-center rounded-lg transition-all duration-300
font-medium text-sm border border-transparent hover:scale-105 hover:shadow-lg
hover:shadow-laser-purple/40 mobile-nav-btn"
:class="$route.path === '/inspirations'
? 'bg-laser-purple/40 text-white border-laser-purple/40 shadow-lg shadow-laser-purple/20'
: 'text-gray-400 hover:text-white hover:bg-dark-light hover:border-laser-purple/30'"
:title="t('inspiration')">
<i class="fi fi-sr-sparkles text-3xl transition-transform duration-300 group-hover:animate-pulse"></i>
</div>
</nav>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<!-- 加载状态 -->
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-laser-purple mx-auto mb-4"></div>
<p class="text-gray-400">{{ t('loading') }}</p>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
import { // 登录相关
loginWithGitHub,
loginWithGoogle,
loginWithSms,
phoneNumber,
verifyCode,
smsCountdown,
showSmsForm,
sendSmsCode,
handleLoginCallback,
handlePhoneEnter,
handleVerifyCodeEnter,
toggleSmsLogin,
isLoggedIn,
loginLoading,
isLoading,
initLoading,
downloadLoading} from '../utils/other'
import { ref } from 'vue';
import { useRouter } from 'vue-router'
const router = useRouter();
</script>
<template>
<div class="login-card">
<div class="card-body text-center p-8">
<!-- Logo和标题 -->
<div>
<div class="login-logo">
<i class="fas fa-film me-3"></i>
LightX2V
</div>
<p class="login-subtitle">{{ t('loginSubtitle') }}</p>
</div>
<div class="space-y-6 w-[80%] mx-auto">
<div>
<label for="phoneNumber" class="block text-sm/6 font-medium text-gray-100 text-left">{{ t('phoneNumber') }}</label>
<div class="mt-2">
<input v-model="phoneNumber" type="tel" name="phoneNumber" required maxlength="11" @keyup.enter="handlePhoneEnter" class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6" />
</div>
</div>
<div>
<div class="flex items-center justify-between">
<label for="verifyCode" class="block text-sm/6 font-medium text-gray-100">{{ t('verifyCode') }}</label>
<div class="text-sm">
<button
@click="sendSmsCode"
class="font-semibold text-[#9a72ff] hover:text-indigo-300"
:disabled="!phoneNumber || smsCountdown > 0 || loginLoading"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : t('sendSmsCode') }}
</button>
</div>
</div>
<div class="mt-2">
<input v-model="verifyCode" type="text" name="verifyCode" required maxlength="6" @keyup.enter="handleVerifyCodeEnter" class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6" />
</div>
</div>
<div>
<button @click="loginWithSms" :disabled="!phoneNumber || !verifyCode || loginLoading" class="btn-submit flex w-full justify-center rounded-md bg-laser-purple px-3 py-1.5 text-sm/6 font-semibold text-white hover:bg-indigo-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500">
{{ loginLoading ? t('loginLoading') : t('login') }}
</button>
</div>
</div>
<!-- 分隔线 -->
<div class="divider mb-4 pt-4">
<span class="divider-text">{{ t('orLoginWith') }}</span>
</div>
<!-- 第三方登录按钮 -->
<div class="social-login-buttons">
<button @click="loginWithGitHub" class="btn btn-icon"
:disabled="loginLoading" :title="t('loginWithGitHub')">
<i class="fab fa-github"></i>
</button>
<button @click="loginWithGoogle" class="btn btn-icon"
:disabled="loginLoading" :title="t('loginWithGoogle')">
<i class="fab fa-google"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
import {
getTemplateFileUrl,
getHistoryImageUrl,
goToTemplatePage,
jumpToTemplatePage,
getVisibleTemplatePages,
selectImageHistory,
selectImageTemplate,
selectAudioHistory,
selectAudioTemplate,
previewAudioHistory,
previewAudioTemplate,
clearImageHistory,
clearAudioHistory,
templatePaginationInfo,
templateCurrentPage,
templatePageInput,
showImageTemplates,
showAudioTemplates,
imageHistory,
audioHistory,
imageTemplates,
audioTemplates,
mediaModalTab,
getImageHistory,
getAudioHistory,
} from '../utils/other'
</script>
<template>
<!-- 模板选择浮窗 -->
<div v-cloak>
<div v-if="showImageTemplates || showAudioTemplates"
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
@click="showImageTemplates = false; showAudioTemplates = false">
<div class="bg-secondary rounded-xl p-6 max-w-4xl w-full mx-4 h-[90vh] overflow-hidden"
@click.stop>
<!-- 浮窗头部 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-white">
<i v-if="showImageTemplates"
class="fas fa-image text-gradient-primary mr-2"></i>
<i v-if="showAudioTemplates"
class="fas fa-music text-gradient-primary mr-2"></i>
{{ showImageTemplates ? t('imageTemplates') : t('audioTemplates') }}
</h3>
<button @click="showImageTemplates = false; showAudioTemplates = false"
class="text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 标签页切换 -->
<div class="flex border-b border-gray-700 mb-6">
<button
@click="mediaModalTab = 'history'; showImageTemplates && getImageHistory(); showAudioTemplates && getAudioHistory()"
class="px-4 py-2 text-sm font-medium transition-colors" :class="mediaModalTab === 'history'
? 'text-gradient-primary border-b-2 border-laser-purple'
: 'text-gray-400 hover:text-gray-300'">
<i class="fas fa-history mr-2"></i>
{{ t('history') }}
</button>
<button @click="mediaModalTab = 'templates'"
class="px-4 py-2 text-sm font-medium transition-colors" :class="mediaModalTab === 'templates'
? 'text-gradient-primary border-b-2 border-laser-purple'
: 'text-gray-400 hover:text-gray-300'">
<i class="fas fa-layer-group mr-2"></i>
{{ t('templates') }}
</button>
</div>
<!-- 图片历史记录 -->
<div v-if="showImageTemplates && mediaModalTab === 'history'"
class="overflow-y-auto flex-1 max-h-[60vh] main-scrollbar">
<div v-if="imageHistory.length === 0"
class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-auto bg-laser-purple/20 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-history text-gradient-primary text-2xl"></i>
</div>
<p class="text-gray-400 text-lg mb-2">{{ t('noHistoryRecords') }}</p>
<p class="text-gray-500 text-sm">{{ t('imageHistoryAutoSave') }}</p>
</div>
<div v-else class="space-y-3">
<div class="flex items-center justify-between mb-4">
<span class="text-sm text-gray-400">{{ t('total') }} {{ imageHistory.length }}
{{ t('records') }}</span>
<button @click="clearImageHistory"
class="text-xs text-red-400 hover:text-red-300 transition-colors flex items-center gap-1"
:title="t('clearHistory')">
<i class="fas fa-trash"></i>
{{ t('clear') }}
</button>
</div>
<div class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4">
<div v-for="(history, index) in imageHistory" :key="index"
@click="selectImageHistory(history)"
class="break-inside-avoid mb-4 relative group cursor-pointer rounded-lg overflow-hidden border border-gray-700 hover:border-laser-purple/50 transition-all">
<img :src="getHistoryImageUrl(history)" :alt="history.filename"
class="w-full h-auto object-contain">
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<i class="fas fa-check text-white text-xl"></i>
</div>
</div>
</div>
</div>
</div>
<!-- 图片模板网格 -->
<div v-if="showImageTemplates && mediaModalTab === 'templates'">
<!-- 图片模板分页组件 -->
<div v-if="templatePaginationInfo" class="mt-8">
<div class="flex items-center justify-between text-xs text-gray-400 mb-4">
<div class="flex items-center space-x-1 text-gray-500">
<span>{{ templatePaginationInfo.total }} {{ t('records') }}</span>
</div>
</div>
<div v-if="templatePaginationInfo.total_pages > 1" class="flex justify-center">
<nav class="isolate inline-flex -space-x-px rounded-md" aria-label="Pagination">
<!-- 上一页按钮 -->
<button @click="goToTemplatePage(templateCurrentPage - 1)"
:disabled="templateCurrentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': templateCurrentPage <= 1 }"
:title="t('previousPage')">
<span class="sr-only">{{ t('previousPage') }}</span>
<i class="fas fa-chevron-left text-sm" aria-hidden="true"></i>
</button>
<!-- 页码按钮 -->
<template v-for="page in getVisibleTemplatePages()" :key="page">
<button v-if="page !== '...'" @click="goToTemplatePage(page)"
:class="[
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
page === templateCurrentPage
? 'z-10 text-white focus-visible:outline-2 focus-visible:outline-offset-2 bg-laser-purple focus-visible:outline-laser-purple'
: 'text-gray-200 inset-ring inset-ring-gray-700 hover:bg-white/5'
]"
:aria-current="page === templateCurrentPage ? 'page' : undefined">
{{ page }}
</button>
<span v-else class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-400 inset-ring inset-ring-gray-700 focus:outline-offset-0">...</span>
</template>
<!-- 下一页按钮 -->
<button @click="goToTemplatePage(templateCurrentPage + 1)"
:disabled="templateCurrentPage >= templatePaginationInfo.total_pages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': templateCurrentPage >= templatePaginationInfo.total_pages }"
:title="t('nextPage')">
<span class="sr-only">{{ t('nextPage') }}</span>
<i class="fas fa-chevron-right text-sm" aria-hidden="true"></i>
</button>
</nav>
</div>
</div>
<div class="overflow-y-auto flex-1 max-h-[60vh] main-scrollbar">
<div v-if="imageTemplates.length > 0" class="columns-2 sm:columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4">
<div v-for="template in imageTemplates" :key="template.filename"
@click="selectImageTemplate(template)"
class="break-inside-avoid mb-4 relative group cursor-pointer rounded-lg border border-gray-700 hover:border-laser-purple/50 transition-all">
<img :src="getTemplateFileUrl(template.filename,'images')" :alt="template.filename"
class="w-full h-auto object-contain" preload="metadata">
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<i class="fas fa-check text-white text-2xl"></i>
</div>
</div>
</div>
<div v-else
class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 bg-laser-purple/20 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-image text-gradient-primary text-2xl"></i>
</div>
<p class="text-gray-400 text-lg mb-2">{{ t('noImageTemplates') }}</p>
</div>
</div>
</div>
<!-- 音频历史记录 -->
<div v-if="showAudioTemplates && mediaModalTab === 'history'"
class="overflow-y-auto flex-1 max-h-[60vh] main-scrollbar">
<div v-if="audioHistory.length === 0"
class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 bg-laser-purple/20 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-history text-gradient-primary text-2xl"></i>
</div>
<p class="text-gray-400 text-lg mb-2">{{ t('noHistoryRecords') }}</p>
<p class="text-gray-500 text-sm">{{ t('audioHistoryAutoSave') }}</p>
</div>
<div v-else class="space-y-3">
<div class="flex items-center justify-between mb-4">
<span class="text-sm text-gray-400">共 {{ audioHistory.length }}
{{ t('records') }}</span>
<button @click="clearAudioHistory"
class="text-xs text-red-400 hover:text-red-300 transition-colors flex items-center gap-1"
:title="t('clearHistory')">
<i class="fas fa-trash"></i>
{{ t('clear') }}
</button>
</div>
<div class="space-y-3">
<div v-for="(history, index) in audioHistory" :key="index"
@click="selectAudioHistory(history)"
class="flex items-center gap-4 p-4 rounded-lg border border-gray-700 hover:border-laser-purple/50 transition-all cursor-pointer bg-dark-light/50 group">
<div
class="w-12 h-12 bg-laser-purple/20 rounded-lg flex items-center justify-center">
<i class="fas fa-music text-gradient-primary text-xl"></i>
</div>
<div class="flex-1">
<div
class="text-white font-medium group-hover:text-gradient-primary transition-colors">
{{ history.filename }}</div>
<div class="text-gray-400 text-sm">{{ t('audioFile') }}</div>
</div>
<button @click.stop="previewAudioHistory(history)"
class="px-3 py-2 bg-laser-purple/20 hover:bg-laser-purple/30 text-gradient-primary rounded-lg transition-all cursor-pointer relative z-10"
style="pointer-events: auto;">
<i class="fas fa-play mr-2"></i>
{{ t('preview') }}
</button>
</div>
</div>
</div>
</div>
<!-- 音频模板列表 -->
<div v-if="showAudioTemplates && mediaModalTab === 'templates'">
<!-- 音频模板分页组件 -->
<div v-if="templatePaginationInfo" class="mt-8">
<div class="flex items-center justify-between text-xs text-gray-400 mb-4">
<div class="flex items-center space-x-1 text-gray-500">
<span>{{ templatePaginationInfo.total }} {{ t('records') }}</span>
</div>
</div>
<div v-if="templatePaginationInfo.total_pages > 1" class="flex justify-center">
<nav class="isolate inline-flex -space-x-px rounded-md" aria-label="Pagination">
<!-- 上一页按钮 -->
<button @click="goToTemplatePage(templateCurrentPage - 1)"
:disabled="templateCurrentPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': templateCurrentPage <= 1 }"
:title="t('previousPage')">
<span class="sr-only">{{ t('previousPage') }}</span>
<i class="fas fa-chevron-left text-sm" aria-hidden="true"></i>
</button>
<!-- 页码按钮 -->
<template v-for="page in getVisibleTemplatePages()" :key="page">
<button v-if="page !== '...'" @click="goToTemplatePage(page)"
:class="[
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
page === templateCurrentPage
? 'z-10 text-white focus-visible:outline-2 focus-visible:outline-offset-2 bg-laser-purple focus-visible:outline-laser-purple'
: 'text-gray-200 inset-ring inset-ring-gray-700 hover:bg-white/5'
]"
:aria-current="page === templateCurrentPage ? 'page' : undefined">
{{ page }}
</button>
<span v-else class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-400 inset-ring inset-ring-gray-700 focus:outline-offset-0">...</span>
</template>
<!-- 下一页按钮 -->
<button @click="goToTemplatePage(templateCurrentPage + 1)"
:disabled="templateCurrentPage >= templatePaginationInfo.total_pages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': templateCurrentPage >= templatePaginationInfo.total_pages }"
:title="t('nextPage')">
<span class="sr-only">{{ t('nextPage') }}</span>
<i class="fas fa-chevron-right text-sm" aria-hidden="true"></i>
</button>
</nav>
</div>
</div>
<div class="overflow-y-auto flex-1 max-h-[60vh] main-scrollbar">
<div v-if="audioTemplates.length > 0" class="space-y-3">
<div v-for="template in audioTemplates" :key="template.filename"
@click="selectAudioTemplate(template)"
class="flex items-center gap-4 p-4 rounded-lg border border-gray-700 hover:border-laser-purple/50 transition-all cursor-pointer bg-dark-light/50">
<div
class="w-12 h-12 bg-laser-purple/20 rounded-lg flex items-center justify-center">
<i class="fas fa-music text-gradient-primary text-xl"></i>
</div>
<div class="flex-1">
<div class="text-white font-medium">{{ template.filename }}
</div>
<div class="text-gray-400 text-sm">{{ t('audioTemplates') }}</div>
</div>
<button @click.stop="previewAudioTemplate(template)"
class="px-3 py-2 bg-laser-purple/20 hover:bg-laser-purple/30 text-gradient-primary rounded-lg transition-all cursor-pointer relative z-10"
style="pointer-events: auto;">
<i class="fas fa-play mr-2"></i>
{{ t('preview') }}
</button>
</div>
</div>
<div v-else
class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 bg-laser-purple/20 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-music text-gradient-primary text-2xl"></i>
</div>
<p class="text-gray-400 text-lg mb-2">目前暂无音频模板</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template>
<DropdownMenu
:items="modelItems"
:selected-value="selectedModel"
:placeholder="t('selectModel')"
:empty-message="t('selectTaskTypeFirst')"
@select-item="handleSelectModel"
/>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from './DropdownMenu.vue'
const { t } = useI18n()
// Props
const props = defineProps({
availableModels: {
type: Array,
default: () => []
},
selectedModel: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['select-model'])
// Computed
const modelItems = computed(() => {
return props.availableModels.map(model => ({
value: model,
label: model,
icon: 'fas fa-cog'
}))
})
// Methods
const handleSelectModel = (item) => {
emit('select-model', item.value)
}
</script>
<script setup>
import { useI18n } from 'vue-i18n'
import { watch } from 'vue'
const { t, locale } = useI18n()
import FloatingParticles from './FloatingParticles.vue'
import TopBar from './TopBar.vue'
import LeftBar from './LeftBar.vue'
import Loading from './Loading.vue'
import { useRoute, useRouter } from 'vue-router'
// Props
const props = defineProps({
query: {
type: Object,
default: () => ({})
},
taskId: {
type: String,
default: null
}
})
import {
submitting,
// 任务类型下拉菜单
showTaskTypeMenu,
showModelMenu,
isLoggedIn,
loading,
loginLoading,
initLoading,
downloadLoading,
// 录音相关
isRecording,
recordingDuration,
startRecording,
stopRecording,
formatRecordingDuration,
taskSearchQuery,
currentUser,
models,
tasks,
alert,
showErrorDetails,
showFailureDetails,
confirmDialog,
showConfirmDialog,
showTaskDetailModal,
modalTask,
t2vForm,
i2vForm,
s2vForm,
getCurrentForm,
i2vImagePreview,
s2vImagePreview,
s2vAudioPreview,
getCurrentImagePreview,
getCurrentAudioPreview,
setCurrentImagePreview,
setCurrentAudioPreview,
updateUploadedContentStatus,
availableTaskTypes,
availableModelClasses,
currentTaskHints,
currentHintIndex,
startHintRotation,
stopHintRotation,
filteredTasks,
selectedTaskId,
selectedTask,
loadingTaskFiles,
statusFilter,
pagination,
paginationInfo,
currentTaskPage,
taskPageSize,
taskPageInput,
paginationKey,
taskMenuVisible,
toggleTaskMenu,
closeAllTaskMenus,
handleClickOutside,
showAlert,
setLoading,
apiCall,
logout,
loadModels,
sidebarCollapsed,
sidebarWidth,
showExpandHint,
showGlow,
isDefaultStateHidden,
hideDefaultState,
showDefaultState,
isCreationAreaExpanded,
hasUploadedContent,
isContracting,
expandCreationArea,
contractCreationArea,
taskFileCache,
taskFileCacheLoaded,
templateFileCache,
templateFileCacheLoaded,
loadTaskFiles,
downloadFile,
viewFile,
handleImageUpload,
selectTask,
selectModel,
resetForm,
triggerImageUpload,
triggerAudioUpload,
removeImage,
removeAudio,
handleAudioUpload,
loadImageAudioTemplates,
selectImageTemplate,
selectAudioTemplate,
previewAudioTemplate,
getTemplateFile,
imageTemplates,
audioTemplates,
showImageTemplates,
showAudioTemplates,
mediaModalTab,
templatePagination,
templatePaginationInfo,
templateCurrentPage,
templatePageSize,
templatePageInput,
templatePaginationKey,
imageHistory,
audioHistory,
showTemplates,
showHistory,
showPromptModal,
promptModalTab,
submitTask,
fileToBase64,
formatTime,
refreshTasks,
goToPage,
jumpToPage,
getVisiblePages,
goToTemplatePage,
jumpToTemplatePage,
getVisibleTemplatePages,
goToInspirationPage,
jumpToInspirationPage,
getVisibleInspirationPages,
preloadTaskFilesUrl,
preloadTemplateFilesUrl,
loadTaskFilesFromCache,
saveTaskFilesToCache,
getTaskFileFromCache,
setTaskFileToCache,
getTaskFileUrlFromApi,
getTaskFileUrl,
getTaskFileUrlSync,
getTemplateFileUrlFromApi,
getTemplateFileUrl,
getTemplateFileUrlAsync,
loadTemplateFilesFromCache,
saveTemplateFilesToCache,
loadFromCache,
saveToCache,
clearAllCache,
getStatusBadgeClass,
viewSingleResult,
cancelTask,
resumeTask,
deleteTask,
startPollingTask,
stopPollingTask,
reuseTask,
showTaskCreator,
toggleSidebar,
clearPrompt,
getTaskItemClass,
getStatusIndicatorClass,
getTaskTypeBtnClass,
getModelBtnClass,
getTaskTypeIcon,
getTaskTypeName,
getPromptPlaceholder,
getStatusTextClass,
getImagePreview,
getTaskInputUrl,
getTaskInputImage,
getTaskInputAudio,
getHistoryImageUrl,
getUserAvatarUrl,
getCurrentImagePreviewUrl,
getCurrentAudioPreviewUrl,
handleThumbnailError,
handleImageError,
handleImageLoad,
handleAudioError,
handleAudioLoad,
getTaskStatusDisplay,
getTaskStatusColor,
getTaskStatusIcon,
getTaskDuration,
getRelativeTime,
getTaskHistory,
getActiveTasks,
getOverallProgress,
getProgressTitle,
getProgressInfo,
getSubtaskProgress,
getSubtaskStatusText,
formatEstimatedTime,
formatDuration,
searchTasks,
filterTasksByStatus,
filterTasksByType,
getAlertClass,
getAlertBorderClass,
getAlertTextClass,
getAlertIcon,
getAlertIconBgClass,
getPromptTemplates,
selectPromptTemplate,
promptHistory,
getPromptHistory,
addTaskToHistory,
getLocalTaskHistory,
selectPromptHistory,
clearPromptHistory,
getImageHistory,
getAudioHistory,
selectImageHistory,
selectAudioHistory,
previewAudioHistory,
clearImageHistory,
clearAudioHistory,
getAudioMimeType,
getAuthHeaders,
startResize,
sidebar,
switchToCreateView,
switchToProjectsView,
switchToInspirationView,
switchToLoginView,
openTaskDetailModal,
closeTaskDetailModal,
// 灵感广场相关
inspirationSearchQuery,
selectedInspirationCategory,
inspirationItems,
InspirationCategories,
loadInspirationData,
selectInspirationCategory,
handleInspirationSearch,
loadMoreInspiration,
inspirationPagination,
inspirationPaginationInfo,
inspirationCurrentPage,
inspirationPageSize,
inspirationPageInput,
inspirationPaginationKey,
// 工具函数
formatDate,
// 模板详情弹窗相关
showTemplateDetailModal,
selectedTemplate,
previewTemplateDetail,
closeTemplateDetailModal,
useTemplate,
// 图片放大弹窗相关
showImageZoomModal,
zoomedImageUrl,
showImageZoom,
closeImageZoomModal,
// 模板素材应用相关
applyTemplateImage,
applyTemplateAudio,
applyTemplatePrompt,
copyPrompt,
// 视频播放控制
playVideo,
pauseVideo,
toggleVideoPlay,
pauseAllVideos,
updateVideoIcon,
onVideoLoaded,
onVideoError,
onVideoEnded,
generateShareUrl,
copyShareLink,
shareToSocial,
openTaskFromRoute
} from '../utils/other'
// 路由监听
const route = useRoute()
const router = useRouter()
// 处理任务下载
const handleDownloadTask = async (task) => {
try {
console.log('开始下载任务文件:', { taskId: task.task_id, outputs: task.outputs })
// 处理文件名,确保有正确的后缀名
let fileName = task.outputs?.output_video || 'video.mp4'
if (fileName && typeof fileName === 'string') {
// 检查是否已有后缀名
const hasExtension = /\.[a-zA-Z0-9]+$/.test(fileName)
if (!hasExtension) {
// 没有后缀名,添加mp4后缀
fileName = `${fileName}.mp4`
console.log('添加后缀名:', fileName)
}
}
// 先尝试从缓存获取
let fileData = getTaskFileFromCache(task.task_id, 'output_video')
console.log('缓存中的文件数据:', fileData)
if (fileData && fileData.blob) {
// 缓存中有blob数据,直接使用
console.log('使用缓存中的文件数据')
downloadFile({ ...fileData, name: fileName })
return
}
if (fileData && fileData.url) {
// 缓存中有URL,使用URL下载
console.log('使用缓存中的URL下载:', fileData.url)
try {
const response = await fetch(fileData.url)
console.log('文件响应状态:', response.status, response.ok)
if (response.ok) {
const blob = await response.blob()
console.log('文件blob大小:', blob.size)
const downloadData = {
blob: blob,
name: fileName
}
console.log('构造的文件数据:', downloadData)
downloadFile(downloadData)
return
} else {
console.error('文件响应失败:', response.status, response.statusText)
}
} catch (error) {
console.error('使用缓存URL下载失败:', error)
}
}
if (!fileData) {
console.log('缓存中没有文件,尝试异步获取...')
// 缓存中没有,尝试异步获取
const url = await getTaskFileUrl(task.task_id, 'output_video')
console.log('获取到的文件URL:', url)
if (url) {
const response = await fetch(url)
console.log('文件响应状态:', response.status, response.ok)
if (response.ok) {
const blob = await response.blob()
console.log('文件blob大小:', blob.size)
fileData = {
blob: blob,
name: fileName
}
console.log('构造的文件数据:', fileData)
} else {
console.error('文件响应失败:', response.status, response.statusText)
}
} else {
console.error('无法获取文件URL')
}
}
if (fileData && fileData.blob) {
console.log('开始下载文件:', fileData.name)
downloadFile(fileData)
} else {
console.error('文件数据无效:', fileData)
showAlert(t('fileUnavailableAlert'), 'danger')
}
} catch (error) {
console.error('下载失败:', error)
showAlert(t('downloadFailedAlert'), 'danger')
}
}
// 监听路由变化,处理任务详情路由
watch(() => route.params.taskId, (newTaskId) => {
if (newTaskId && route.name === 'TaskDetail') {
openTaskFromRoute(newTaskId)
}
}, { immediate: true })
// 监听Projects页面的查询参数
watch(() => route.query, (newQuery) => {
// 同步URL参数到组件状态
if (newQuery.search) {
taskSearchQuery.value = newQuery.search
}
if (newQuery.status) {
statusFilter.value = newQuery.status
}
if (newQuery.page) {
const page = parseInt(newQuery.page)
if (page > 0 && page !== currentTaskPage.value) {
goToPage(page)
}
}
}, { immediate: true })
// 监听组件状态变化,同步到URL
watch([taskSearchQuery, statusFilter, currentTaskPage], () => {
const query = {}
if (taskSearchQuery.value) {
query.search = taskSearchQuery.value
}
if (statusFilter.value && statusFilter.value !== 'ALL') {
query.status = statusFilter.value
}
if (currentTaskPage.value > 1) {
query.page = currentTaskPage.value.toString()
}
// 更新URL但不触发路由监听
router.replace({ query })
})
</script>
<template>
<!-- 历史任务区域 -->
<div class="flex-1 flex flex-col min-h-0 mobile-content">
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto p-10 content-area main-scrollbar">
<!-- 历史任务功能区 -->
<div class="max-w-4xl mx-auto" id="task-creator">
<!-- 搜索和筛选区域 -->
<div class="flex flex-col md:flex-row gap-4 mb-6">
<!-- 搜索框 -->
<div class="relative flex-1">
<i
class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none z-10"></i>
<input v-model="taskSearchQuery"
class="w-full bg-dark-light border border-laser-purple/30 rounded-lg py-3 pl-10 pr-4 text-sm focus:outline-none focus:ring-2 focus:ring-laser-purple/50 transition-all focus:border-laser focus:shadow-laser"
:placeholder="t('searchTasks')" type="text" />
</div>
<!-- 分类筛选 -->
<div class="flex gap-2">
<button @click="statusFilter = 'ALL'"
:class="statusFilter === 'ALL'
? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'"
class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ t('all') }}
</button>
<button @click="statusFilter = 'SUCCEED'"
:class="statusFilter === 'SUCCEED'
? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'"
class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ t('success') }}
</button>
<button @click="statusFilter = 'RUNNING'"
:class="statusFilter === 'RUNNING'
? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'"
class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ t('running') }}
</button>
<button @click="statusFilter = 'FAILED'"
:class="statusFilter === 'FAILED'
? 'bg-laser-purple/20 text-white border-laser-purple/40'
: 'bg-dark-light text-gray-400 hover:text-white hover:bg-dark-light/80'"
class="px-4 py-2 rounded-lg border border-transparent transition-all duration-200 text-sm font-medium">
{{ t('failed') }}
</button>
<button @click="() => refreshTasks(true)"
class="text-gray-400 hover:text-gradient-primary transition-colors flex-shrink-0"
:title="t('refreshTasks')">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<!-- 分页组件 -->
<div v-cloak>
<div v-if="paginationInfo" :key="paginationKey" class="pagination-container mb-3">
<div class="flex items-center justify-between text-xs text-gray-400">
<div class="flex items-center space-x-1 text-gray-500">
<span>{{ t('total') }} {{ paginationInfo.total }} {{ t('tasks') }}</span>
</div>
<div v-if="paginationInfo.total_pages > 1" class="flex justify-center">
<nav class="isolate inline-flex -space-x-px rounded-md" aria-label="Pagination">
<!-- 上一页按钮 -->
<button @click="goToPage(currentTaskPage - 1)"
:disabled="currentTaskPage <= 1"
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': currentTaskPage <= 1 }"
:title="t('previousPage')">
<span class="sr-only">{{ t('previousPage') }}</span>
<i class="fas fa-chevron-left text-sm" aria-hidden="true"></i>
</button>
<!-- 页码按钮 -->
<template v-for="page in getVisiblePages()" :key="page">
<button v-if="page !== '...'" @click="goToPage(page)"
:class="[
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
page === currentTaskPage
? 'z-10 text-white focus-visible:outline-2 focus-visible:outline-offset-2 bg-laser-purple focus-visible:outline-laser-purple'
: 'text-gray-200 inset-ring inset-ring-gray-700 hover:bg-white/5'
]"
:aria-current="page === currentTaskPage ? 'page' : undefined">
{{ page }}
</button>
<span v-else class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-400 inset-ring inset-ring-gray-700 focus:outline-offset-0">...</span>
</template>
<!-- 下一页按钮 -->
<button @click="goToPage(currentTaskPage + 1)"
:disabled="currentTaskPage >= paginationInfo.total_pages"
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-700 hover:bg-white/5 focus:z-20 focus:outline-offset-0"
:class="{ 'opacity-50 cursor-not-allowed': currentTaskPage >= paginationInfo.total_pages }"
:title="t('nextPage')">
<span class="sr-only">{{ t('nextPage') }}</span>
<i class="fas fa-chevron-right text-sm" aria-hidden="true"></i>
</button>
</nav>
</div>
</div>
</div>
</div>
<!-- 可滚动的任务列表区域 -->
<div class="flex-1 overflow-y-auto main-scrollbar min-h-0 p-4"
style="overflow-x: visible;">
<div v-cloak>
<div v-if="filteredTasks.length === 0"
class="flex-col items-center justify-center py-12 text-center">
<p class="text-gray-400 text-sm">{{ t('noHistoryTasks') }}</p>
<p class="text-gray-500 text-xs mt-1">{{ t('startToCreateYourFirstAIVideo') }}</p>
</div>
<div v-else
class="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-3">
<!-- 任务卡片 -->
<div v-for="task in filteredTasks" :key="task.task_id"
class="break-inside-avoid mb-3 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20">
<!-- 缩略图区域 -->
<div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="openTaskDetailModal(task)"
:title="t('viewTaskDetails')">
<!-- 视频预览 -->
<!-- 成功任务:显示视频动图 -->
<video v-if="task.status === 'SUCCEED' && task.outputs?.output_video"
:src="getTaskFileUrlSync(task.task_id, 'output_video')"
:poster="getTaskFileUrlSync(task.task_id, 'input_image')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)"
@mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 其他状态:显示输入图片或占位符 -->
<img v-else="task.inputs?.input_image"
:src="getTaskFileUrlSync(task.task_id, 'input_image')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" />
<!-- 移动端播放按钮 -->
<button v-if="task.status === 'SUCCEED'"
@click.stop="toggleVideoPlay($event)"
class="md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 状态指示器 -->
<div class="absolute top-2 right-2">
<span :class="getTaskStatusColor(task.status)"
class="px-2 py-1 rounded-full text-xs font-medium bg-black/50 backdrop-blur-sm">
{{ getTaskStatusDisplay(task.status) }}
</span>
</div>
<!-- 悬停时显示的操作按钮(桌面端) -->
<div
class="hidden md:block absolute bottom-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
<div class="flex space-x-2 sm:space-x-3 pointer-events-auto">
<button
v-if="['CREATED', 'PENDING', 'RUNNING','SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="reuseTask(task)"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-white/30 transition-colors"
:title="t('reuseTask')">
<i class="fas fa-copy text-sm sm:text-lg"></i>
</button>
<button
v-if="['CREATED', 'PENDING', 'RUNNING'].includes(task.status)"
@click.stop="cancelTask(task.task_id)"
class="w-10 h-10 rounded-full bg-red-400/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('cancelTask')">
<i class="fas fa-times text-sm sm:text-lg"></i>
</button>
<button
v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="resumeTask(task.task_id)"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('retryTask')">
<i class="fas fa-redo text-sm sm:text-lg"></i>
</button>
<button v-if="task.status === 'SUCCEED'"
@click.stop="handleDownloadTask(task)"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-laser-purple/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('downloadTask')">
<i class="fas fa-download text-sm sm:text-lg"></i>
</button>
<button
v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(task.status)"
@click.stop="deleteTask(task.task_id)"
class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-red-400/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-500 transition-colors"
:title="t('deleteTask')">
<i class="fas fa-trash text-sm sm:text-lg"></i>
</button>
</div>
</div>
</div>
<!-- 任务信息 -->
<div class="pl-4 pr-4 pb-4">
<h3 class="text-white font-medium text-sm mb-2 line-clamp-2">{{
task.params.prompt.length > 20 ? task.params.prompt.slice(0, 20)
+ '···' : task.params.prompt }}</h3>
<div
class="flex items-center justify-between text-xs text-gray-500">
<span>{{ task.model_cls }}</span>
<span>{{ getRelativeTime(task.create_t) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { showPromptModal,
promptModalTab,
getPromptTemplates,
selectPromptTemplate,
promptHistory,
selectPromptHistory } from '../utils/other'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<!-- 提示词模板和历史记录弹窗 -->
<div v-cloak>
<div v-if="showPromptModal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
@click="showPromptModal = false">
<div class="bg-secondary rounded-xl p-6 max-w-4xl w-full mx-4 max-h-[80vh] overflow-hidden"
@click.stop>
<!-- 浮窗头部 -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-white">
<i class="fas fa-lightbulb text-gradient-primary mr-2"></i>
{{ t('promptTemplates') }}
</h3>
<button @click="showPromptModal = false"
class="text-gray-400 hover:text-white transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 标签页切换 -->
<div class="flex border-b border-gray-700 mb-6">
<button @click="promptModalTab = 'templates'"
class="px-4 py-2 text-sm font-medium transition-colors" :class="promptModalTab === 'templates'
? 'text-gradient-primary border-b-2 border-laser-purple'
: 'text-gray-400 hover:text-gray-300'">
<i class="fas fa-layer-group mr-2"></i>
{{ t('templates') }}
</button>
<button @click="promptModalTab = 'history'"
class="px-4 py-2 text-sm font-medium transition-colors" :class="promptModalTab === 'history'
? 'text-gradient-primary border-b-2 border-laser-purple'
: 'text-gray-400 hover:text-gray-300'">
<i class="fas fa-history mr-2"></i>
{{ t('history') }}
</button>
</div>
<!-- 模板内容 -->
<div v-if="promptModalTab === 'templates'" class="overflow-y-auto max-h-[50vh]">
<div v-if="getPromptTemplates(selectedTaskId).length > 0"
class="grid grid-cols-1 md:grid-cols-2 gap-4 overflow-y-auto max-h-[50vh] main-scrollbar">
<button v-for="template in getPromptTemplates(selectedTaskId)" :key="template.id"
@click="selectPromptTemplate(template)"
class="break-inside-avoid mb-4 p-4 text-left bg-dark-light rounded-lg hover:bg-laser-purple/20 transition-all border border-transparent hover:border-laser-purple/40 group">
<div
class="font-medium text-sm mb-2 text-white group-hover:text-gradient-primary transition-colors">
{{ template.title }}
</div>
<div class="text-xs text-gray-400 line-clamp-3 leading-relaxed">
{{ template.prompt }}
</div>
<div class="mt-3 flex items-center justify-between">
<span class="text-xs text-laser-purple/60">{{ t('clickApply') }}</span>
<i
class="fas fa-arrow-right text-xs text-laser-purple/60 group-hover:translate-x-1 transition-transform"></i>
</div>
</button>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 bg-laser-purple/20 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-layer-group text-gradient-primary text-2xl"></i>
</div>
<p class="text-gray-400 text-lg mb-2">{{ t('noAvailableTemplates') }}</p>
<p class="text-gray-500 text-sm">{{ t('pleaseSelectTaskType') }}</p>
</div>
</div>
<!-- 历史记录内容 -->
<div v-if="promptModalTab === 'history'" class="overflow-y-auto max-h-[50vh]">
<div v-if="promptHistory.length === 0"
class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 bg-laser-purple/20 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-history text-gradient-primary text-2xl"></i>
</div>
<p class="text-gray-400 text-lg mb-2">{{ t('noHistoryRecords') }}</p>
<p class="text-gray-500 text-sm">{{ t('promptHistoryAutoSave') }}</p>
</div>
<div v-else class="space-y-3">
<div class="flex items-center justify-between mb-4">
<span class="text-sm text-gray-400">{{ promptHistory.length }} {{ t('records') }}</span>
<button @click="clearPromptHistory"
class="text-xs text-red-400 hover:text-red-300 transition-colors flex items-center gap-1"
title="{{ t('clearHistory') }}">
<i class="fas fa-trash"></i>
{{ t('clear') }}
</button>
</div>
<button v-for="(history, index) in promptHistory" :key="index"
@click="selectPromptHistory(history)"
class="w-full p-4 text-left bg-dark-light rounded-lg hover:bg-laser-purple/20 transition-all border border-transparent hover:border-laser-purple/40 group">
<div
class="text-sm text-gray-300 line-clamp-3 leading-relaxed group-hover:text-white transition-colors">
{{ history }}
</div>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-laser-purple/60">{{ t('clickApply') }}</span>
<i
class="fas fa-arrow-right text-xs text-laser-purple/60 group-hover:translate-x-1 transition-transform"></i>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { showTaskDetailModal,
modalTask,
closeTaskDetailModal,
cancelTask,
reuseTask,
downloadFile,
deleteTask,
getTaskTypeName,
formatTime,
getTaskStatusDisplay,
getStatusTextClass,
getProgressTitle,
getProgressInfo,
getOverallProgress,
getSubtaskStatusText,
getSubtaskProgress,
formatEstimatedTime,
generateShareUrl,
copyShareLink,
shareToSocial,
copyPrompt,
getTaskFileUrlSync,
getTaskFileFromCache,
getTaskFileUrl,
showAlert,
apiRequest,
startPollingTask,
resumeTask,
} from '../utils/other'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
// 添加响应式变量
const showDetails = ref(false)
const loadingTaskFiles = ref(false)
// 获取图片素材
const getImageMaterials = () => {
if (!modalTask.value?.inputs?.input_image) return []
return [['input_image', getTaskFileUrlSync(modalTask.value.task_id, 'input_image')]]
}
// 获取音频素材
const getAudioMaterials = () => {
if (!modalTask.value?.inputs?.input_audio) return []
return [['input_audio', getTaskFileUrlSync(modalTask.value.task_id, 'input_audio')]]
}
// 路由关闭功能
const closeWithRoute = () => {
closeTaskDetailModal()
modalTask.value = null
// 如果是从路由进入的,可以返回到上一页
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/')
}
}
// 键盘事件处理
const handleKeydown = (event) => {
if (event.key === 'Escape' && showTaskDetailModal.value) {
closeWithRoute()
}
}
// 处理文件下载
const handleDownloadFile = async (taskId, fileKey, fileName) => {
try {
console.log('开始下载文件:', { taskId, fileKey, fileName })
// 处理文件名,确保有正确的后缀名
let finalFileName = fileName
if (fileName && typeof fileName === 'string') {
// 检查是否已有后缀名
const hasExtension = /\.[a-zA-Z0-9]+$/.test(fileName)
if (!hasExtension) {
// 没有后缀名,根据文件类型添加
const extension = getFileExtension(fileKey)
finalFileName = `${fileName}.${extension}`
console.log('添加后缀名:', finalFileName)
}
} else {
// 没有文件名,使用默认名称
finalFileName = `${fileKey}.${getFileExtension(fileKey)}`
}
// 先尝试从缓存获取
let fileData = getTaskFileFromCache(taskId, fileKey)
console.log('缓存中的文件数据:', fileData)
if (fileData && fileData.blob) {
// 缓存中有blob数据,直接使用
console.log('使用缓存中的文件数据')
downloadFile({ ...fileData, name: finalFileName })
return
}
if (fileData && fileData.url) {
// 缓存中有URL,使用URL下载
console.log('使用缓存中的URL下载:', fileData.url)
try {
const response = await fetch(fileData.url)
console.log('文件响应状态:', response.status, response.ok)
if (response.ok) {
const blob = await response.blob()
console.log('文件blob大小:', blob.size)
const downloadData = {
blob: blob,
name: finalFileName
}
console.log('构造的文件数据:', downloadData)
downloadFile(downloadData)
return
} else {
console.error('文件响应失败:', response.status, response.statusText)
}
} catch (error) {
console.error('使用缓存URL下载失败:', error)
}
}
if (!fileData) {
console.log('缓存中没有文件,尝试异步获取...')
// 缓存中没有,尝试异步获取
const url = await getTaskFileUrl(taskId, fileKey)
console.log('获取到的文件URL:', url)
if (url) {
const response = await fetch(url)
console.log('文件响应状态:', response.status, response.ok)
if (response.ok) {
const blob = await response.blob()
console.log('文件blob大小:', blob.size)
fileData = {
blob: blob,
name: finalFileName
}
console.log('构造的文件数据:', fileData)
} else {
console.error('文件响应失败:', response.status, response.statusText)
}
} else {
console.error('无法获取文件URL')
}
}
if (fileData && fileData.blob) {
console.log('开始下载文件:', fileData.name)
downloadFile(fileData)
} else {
console.error('文件数据无效:', fileData)
showAlert(t('fileUnavailableAlert'), 'danger')
}
} catch (error) {
console.error('下载失败:', error)
showAlert(t('downloadFailedAlert'), 'danger')
}
}
// 获取文件扩展名
const getFileExtension = (fileKey) => {
if (fileKey.includes('video')) return 'mp4'
if (fileKey.includes('image')) return 'jpg'
if (fileKey.includes('audio')) return 'mp3'
return 'file'
}
const getTaskFailureInfo = (task) => {
if (!task) return null;
// 检查子任务的失败信息
if (!task.fail_msg && task.subtasks && task.subtasks.length > 0) {
const failedSubtasks = task.subtasks.filter(subtask =>
(subtask.extra_info && subtask.extra_info.fail_msg) || subtask.fail_msg
);
if (failedSubtasks.length > 0) {
const msg = failedSubtasks.map(subtask =>
(subtask.extra_info && subtask.extra_info.fail_msg) || subtask.fail_msg
).join('\n');
task.fail_msg = msg;
}
}
console.log('task.fail_msg', task.fail_msg);
return task.fail_msg;
};
const viewTaskDetail = async (task) => {
try {
const response = await apiRequest(`/api/v1/task/query?task_id=${task.task_id}`);
console.log('viewTaskDetail: response=', response);
if (response && response.ok) {
modalTask.value = await response.json();
console.log('updated task data:', modalTask.value);
}
} catch (error) {
console.warn(`Failed to fetch updated task data: task_id=${task.task_id}`, error.message);
}
// 如果任务还在进行中,开始轮询状态
if (['CREATED', 'PENDING', 'RUNNING'].includes(task.status)) {
startPollingTask(task.task_id);
}
if (['FAILED'].includes(task.status)) {
modalTask.value.fail_msg = getTaskFailureInfo(task);
}
};
// 监听modalTask的第一次变化,确保任务详情正确加载
const hasLoadedTask = ref(false);
watch(modalTask, (newTask) => {
if (newTask && !hasLoadedTask.value) {
console.log('modalTask第一次变化,加载任务详情:', newTask);
viewTaskDetail(newTask);
hasLoadedTask.value = true;
}
}, { immediate: true });
// 生命周期钩子
onMounted(async () => {
document.addEventListener('keydown', handleKeydown)
console.log('TaskDetails组件已挂载,当前modalTask:', modalTask.value);
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<!-- 任务详情弹窗 -->
<div v-cloak>
<div v-if="showTaskDetailModal"
class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4"
@click="closeWithRoute">
<!-- 任务完成时的大弹窗 -->
<div v-if="modalTask?.status === 'SUCCEED'"
class="landing-page" @click.stop>
<!-- 弹窗头部 -->
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-info-circle mr-2"></i>
{{ t('taskDetails') }}
</h3>
<div class="header-actions">
<button @click="closeWithRoute" class="action-button back-button" :title="t('back')">
<i class="fas fa-arrow-left"></i>
</button>
<button @click="closeWithRoute" class="action-button close-button" :title="t('close')">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content main-scrollbar overflow-y-auto">
<!-- 分享内容 -->
<div class="content-grid">
<!-- 左侧视频区域 -->
<div class="video-section">
<div class="video-container">
<!-- 视频播放器 -->
<video
v-if="modalTask?.outputs?.output_video"
:src="getTaskFileUrlSync(modalTask.task_id, 'output_video')"
:poster="getTaskFileUrlSync(modalTask.task_id, 'input_image')"
class="video-player"
controls
autoplay
loop
preload="metadata"
@loadstart="onVideoLoadStart"
@canplay="onVideoCanPlay"
@error="onVideoError">
{{ t('browserNotSupported') }}
</video>
<div v-else class="video-placeholder">
<div class="loading-spinner">
<i class="fas fa-video"></i>
</div>
<p class="loading-text">{{ t('videoNotAvailable') }}</p>
</div>
</div>
</div>
<!-- 右侧信息区域 -->
<div class="info-section">
<div class="info-content">
<!-- 标题 -->
<div class="title-container">
<h1 class="main-title">
<span v-if="modalTask?.status === 'SUCCEED'">{{ t('taskCompleted') }}</span>
<span v-else-if="modalTask?.status === 'FAILED'">{{ t('taskFailed') }}</span>
<span v-else-if="modalTask?.status === 'CANCEL'">{{ t('taskCancelled') }}</span>
<span v-else-if="modalTask?.status === 'RUNNING'">{{ t('taskRunning') }}</span>
<span v-else-if="modalTask?.status === 'PENDING'">{{ t('taskPending') }}</span>
<span v-else>{{ t('taskDetails') }}</span>
</h1>
</div>
<!-- 描述 -->
<p class="main-description">
{{ t('taskCompletedSuccessfully') }}
</p>
<div class="features-list justify-between">
<div class="feature-item">
<i class="fas fa-toolbox feature-icon"></i>
<span>{{ getTaskTypeName(modalTask) }}</span>
</div>
<div class="feature-item cursor-pointer">
<i class="fas fa-robot feature-icon"></i>
<span>{{ modalTask.model_cls }}</span>
</div>
<div class="feature-item">
<i class="fas fa-clock feature-icon"></i>
<span>{{ t('timeCost')}}{{ Math.round(modalTask.extra_info?.active_elapse || 0) }}s</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<button v-if="modalTask?.outputs?.output_video"
@click="handleDownloadFile(modalTask.task_id, 'output_video', modalTask.outputs.output_video)" class="primary-button">
<i class="fas fa-download mr-2"></i>
{{ t('downloadVideo') }}
</button>
<button @click="reuseTask(modalTask); closeTaskDetailModal()" class="primary-button">
<i class="fas fa-magic mr-2"></i>
{{ t('reuseTask') }}
</button>
<button
@click="copyShareLink(modalTask.task_id, 'task')" class="secondary-button">
<i class="fas fa-share-alt mr-2"></i>
{{ t('copyShareLink') }}
</button>
<button
@click="deleteTask(modalTask.task_id, true); closeTaskDetailModal()"
class="secondary-button">
<i class="fas fa-trash mr-2"></i>
{{ t('deleteTask') }}
</button>
<button @click="showDetails = !showDetails" class="secondary-button">
<i :class="showDetails ? 'fas fa-chevron-up' : 'fas fa-info-circle'" class="mr-2"></i>
{{ showDetails ? t('hideDetails') : t('showDetails') }}
</button>
</div>
<!-- 技术信息 -->
<div class="tech-info">
<p class="tech-text">
<a href="https://github.com/ModelTC/LightX2V" target="_blank" rel="noopener noreferrer" class="tech-link">
{{ t('poweredByLightX2V') }}
</a>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 详细信息面板 -->
<div v-if="showDetails && modalTask" class="details-panel">
<div class="details-content">
<!-- 输入素材标题 -->
<h2 class="materials-title">
<i class="fas fa-upload mr-2"></i>
{{ t('inputMaterials') }}
</h2>
<!-- 三个并列的分块卡片 -->
<div class="materials-cards">
<!-- 图片卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-image card-icon"></i>
<h3 class="card-title">{{ t('image') }}</h3>
<div class="card-actions">
<button v-if="getImageMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_image', modalTask.inputs.input_image)"
class="action-btn download-btn"
:title="t('download')">
<i class="fas fa-download"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="getImageMaterials().length > 0" class="image-grid">
<div v-for="[inputName, url] in getImageMaterials()" :key="inputName" class="image-item">
<div class="image-container">
<img :src="url" :alt="inputName" class="image-preview">
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-image empty-icon"></i>
<p class="empty-text">{{ t('noImage') }}</p>
</div>
</div>
</div>
<!-- 音频卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-music card-icon"></i>
<h3 class="card-title">{{ t('audio') }}</h3>
<div class="card-actions">
<button v-if="getAudioMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_audio', modalTask.inputs.input_audio)"
class="action-btn download-btn"
:title="t('download')">
<i class="fas fa-download"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="getAudioMaterials().length > 0" class="audio-list">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName" class="audio-item">
<audio :src="url" controls class="audio-player"></audio>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-music empty-icon"></i>
<p class="empty-text">{{ t('noAudio') }}</p>
</div>
</div>
</div>
<!-- 提示词卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-file-alt card-icon"></i>
<h3 class="card-title">{{ t('prompt') }}</h3>
<div class="card-actions">
<button v-if="modalTask?.params?.prompt"
@click="copyPrompt(modalTask?.params?.prompt)"
class="action-btn copy-btn"
:title="t('copy')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="modalTask?.params?.prompt" class="prompt-content">
<p class="prompt-text">{{ modalTask.params.prompt }}</p>
</div>
<div v-else class="empty-state">
<i class="fas fa-file-alt empty-icon"></i>
<p class="empty-text">{{ t('noPrompt') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 其他状态的弹窗 -->
<div v-else class="landing-page" @click.stop>
<!-- 弹窗头部 -->
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-info-circle mr-2"></i>
{{ t('taskDetails') }}
</h3>
<div class="header-actions">
<button @click="closeWithRoute" class="action-button back-button" :title="t('back')">
<i class="fas fa-arrow-left"></i>
</button>
<button @click="closeTaskDetailModal" class="action-button close-button" :title="t('close')">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content main-scrollbar overflow-y-auto">
<div class="content-grid">
<!-- 左侧占位图区域 -->
<div class="video-section">
<div class="video-container">
<!-- 根据状态显示不同的占位图 -->
<div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)" class="video-placeholder">
<!-- 如果有图像输入,显示灰一点的图像作为背景 -->
<div v-if="getImageMaterials().length > 0" class="background-image">
<img :src="getImageMaterials()[0][1]" :alt="getImageMaterials()[0][0]" class="dimmed-image">
</div>
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
<p class="loading-text">{{ t('videoGenerating') }}...</p>
</div>
<div v-else-if="modalTask?.status === 'FAILED'" class="video-placeholder error-placeholder">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<p class="loading-text">{{ t('videoGeneratingFailed') }}</p>
</div>
<div v-else-if="modalTask?.status === 'CANCEL'" class="video-placeholder cancel-placeholder">
<div class="cancel-icon">
<i class="fas fa-ban"></i>
</div>
<p class="loading-text">{{ t('taskCancelled') }}</p>
</div>
</div>
</div>
<!-- 右侧信息区域 -->
<div class="info-section">
<div class="info-content">
<!-- 标题和状态 -->
<div class="title-container">
<h1 class="main-title">
<span v-if="modalTask?.status === 'SUCCEED'">{{ t('taskCompleted') }}</span>
<span v-else-if="modalTask?.status === 'FAILED'">{{ t('taskFailed') }}</span>
<span v-else-if="modalTask?.status === 'CANCEL'">{{ t('taskCancelled') }}</span>
<span v-else-if="modalTask?.status === 'RUNNING'">{{ t('taskRunning') }}</span>
<span v-else-if="modalTask?.status === 'PENDING'">{{ t('taskPending') }}</span>
<span v-else>{{ t('taskDetails') }}</span>
</h1>
</div>
<!-- 进度条 -->
<div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)">
<div v-for="(subtask, index) in (modalTask.subtasks || [])" :key="index">
<!-- PENDING状态:显示排队信息 -->
<div v-if="subtask.status === 'PENDING'" class="queue-info">
<div v-if="subtask.estimated_pending_order !== null" class="queue-visualization">
<div class="queue-people">
<i v-for="n in Math.min(subtask.estimated_pending_order, 10)"
:key="n"
class="fas fa-user queue-person"></i>
<span v-if="subtask.estimated_pending_order > 10" class="queue-more">
+{{ subtask.estimated_pending_order - 10 }}
</span>
</div>
<span class="queue-text">
{{ t('queuePosition') }}: {{ subtask.estimated_pending_order }}
</span>
</div>
</div>
<!-- RUNNING状态:显示进度条 -->
<div v-else-if="subtask.status === 'RUNNING'" class="progress-container">
<div class="minimal-progress-bar">
<div class="progress-line">
<div class="progress-fill" :style="{ width: getSubtaskProgress(subtask) + '%' }"></div>
<div class="moving-dot" :style="{ left: getSubtaskProgress(subtask) + '%' }"></div>
</div>
</div>
<div class="progress-info">
<span class="estimated-time">
{{ getSubtaskProgress(subtask) }}%
</span>
</div>
</div>
</div>
</div>
<!-- 描述 -->
<p class="main-description mt-4">
<span v-if="['RUNNING'].includes(modalTask?.status)">
{{ t('aiIsGeneratingYourVideo') }}
</span>
<span v-else-if="['CREATED'].includes(modalTask?.status)">
{{ t('taskSubmittedSuccessfully') }}
</span>
<span v-else-if="['PENDING'].includes(modalTask?.status)">
{{ t('taskQueuePleaseWait') }}
</span>
<span v-else-if="modalTask?.status === 'FAILED'">
{{ t('sorryYourVideoGenerationTaskFailed') }}
<button v-if="modalTask?.fail_msg" @click="showFailureDetails = !showFailureDetails" class="text-red-400 hover:text-red-300 transition-colors">
<i class="fas fa-exclamation-triangle text-xs"></i>
</button>
<div v-if="showFailureDetails && modalTask?.fail_msg" class="mt-4 p-3 bg-red-900/20 border border-red-500/30 rounded-lg">
<div class="flex items-start gap-2">
<i class="fas fa-exclamation-triangle text-red-400 text-sm mt-0.5"></i>
<div class="flex-1">
<p class="text-xs text-red-300 font-medium mb-1">{{ t('failureReason') }}:</p>
<p class="text-xs text-red-200 whitespace-pre-wrap">{{ modalTask?.fail_msg }}</p>
</div>
</div>
</div>
</span>
<span v-else-if="modalTask?.status === 'CANCEL'">
{{ t('thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore') }}
</span>
</p>
<div class="features-list justify-between">
<div class="feature-item">
<i class="fas fa-toolbox feature-icon"></i>
<span>{{ getTaskTypeName(modalTask) }}</span>
</div>
<div class="feature-item cursor-pointer">
<i class="fas fa-robot feature-icon"></i>
<span>{{ modalTask.model_cls }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<!-- 进行中状态 -->
<button v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
@click="cancelTask(modalTask.task_id, true); closeTaskDetailModal()"
class="primary-button">
<i class="fas fa-times mr-2"></i>
{{ t('cancelTask') }}
</button>
<!-- 失败状态 -->
<button v-if="modalTask?.status === 'FAILED'"
@click="resumeTask(modalTask.task_id, true); closeTaskDetailModal()"
class="primary-button">
<i class="fas fa-redo mr-2"></i>
{{ t('retryTask') }}
</button>
<!-- 取消状态 -->
<button v-if="modalTask?.status === 'CANCEL'"
@click="resumeTask(modalTask.task_id, true); closeTaskDetailModal()"
class="primary-button">
<i class="fas fa-redo mr-2"></i>
{{ t('regenerateTask') }}
</button>
<!-- 通用按钮 -->
<button v-if="['SUCCEED', 'FAILED', 'CANCEL','CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
@click="reuseTask(modalTask); closeTaskDetailModal()"
class="secondary-button">
<i class="fas fa-copy mr-2"></i>
{{ t('reuseTask') }}
</button>
<button v-if="['SUCCEED', 'FAILED', 'CANCEL'].includes(modalTask?.status)"
@click="deleteTask(modalTask.task_id, true); closeTaskDetailModal()"
class="secondary-button">
<i class="fas fa-trash mr-2"></i>
{{ t('deleteTask') }}
</button>
<button @click="showDetails = !showDetails" class="secondary-button">
<i :class="showDetails ? 'fas fa-chevron-up' : 'fas fa-info-circle'" class="mr-2"></i>
{{ showDetails ? t('hideDetails') : t('showDetails') }}
</button>
</div>
<!-- 技术信息 -->
<div class="tech-info">
<p class="tech-text">
<a href="https://github.com/ModelTC/LightX2V" target="_blank" rel="noopener noreferrer" class="tech-link">
{{ t('poweredByLightX2V') }}
</a>
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 详细信息面板 -->
<div v-if="showDetails && modalTask" class="details-panel">
<div class="details-content">
<!-- 输入素材标题 -->
<div class="materials-header">
<h2 class="materials-title">
<i class="fas fa-upload mr-2"></i>
{{ t('inputMaterials') }}
</h2>
</div>
<!-- 三个并列的分块卡片 -->
<div class="materials-cards">
<!-- 图片卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-image card-icon"></i>
<h3 class="card-title">{{ t('image') }}</h3>
<div class="card-actions">
<button v-if="getImageMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_image', modalTask.inputs.input_image)"
class="action-btn download-btn"
:title="t('download')">
<i class="fas fa-download"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="getImageMaterials().length > 0" class="image-grid">
<div v-for="[inputName, url] in getImageMaterials()" :key="inputName" class="image-item">
<div class="image-container">
<img :src="url" :alt="inputName" class="image-preview">
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-image empty-icon"></i>
<p class="empty-text">{{ t('noImage') }}</p>
</div>
</div>
</div>
<!-- 音频卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-music card-icon"></i>
<h3 class="card-title">{{ t('audio') }}</h3>
<div class="card-actions">
<button v-if="getAudioMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_audio', modalTask.inputs.input_audio)"
class="action-btn download-btn"
:title="t('download')">
<i class="fas fa-download"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="getAudioMaterials().length > 0" class="audio-list">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName" class="audio-item">
<audio :src="url" controls class="audio-player"></audio>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-music empty-icon"></i>
<p class="empty-text">{{ t('noAudio') }}</p>
</div>
</div>
</div>
<!-- 提示词卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-file-alt card-icon"></i>
<h3 class="card-title">{{ t('prompt') }}</h3>
<div class="card-actions">
<button v-if="modalTask?.params?.prompt"
@click="copyPrompt(modalTask?.params?.prompt)"
class="action-btn copy-btn"
:title="t('copy')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="modalTask?.params?.prompt" class="prompt-content">
<p class="prompt-text">{{ modalTask.params.prompt }}</p>
</div>
<div v-else class="empty-state">
<i class="fas fa-file-alt empty-icon"></i>
<p class="empty-text">{{ t('noPrompt') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Landing Page 样式 */
.landing-page {
min-height: calc(100vh - 60px);
width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
color: white;
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: white;
margin: 0;
display: flex;
align-items: center;
}
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.action-button {
background: none;
border: none;
color: #9ca3af;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
padding: 0.5rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.action-button:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.back-button:hover {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.close-button:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.main-content {
width: 100%;
padding: 2rem 0;
min-height: calc(100vh - 80px);
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
}
/* 内容网格布局 */
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
width: 100%;
margin: 0 auto;
padding: 0 2rem;
align-items: center;
min-height: 60vh;
}
/* 视频区域 */
.video-section {
display: flex;
justify-content: center;
align-items: center;
}
.video-container {
width: 100%;
max-width: 500px;
aspect-ratio: 9/16;
background: #000;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
position: relative;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1f2937;
position: relative;
}
/* 占位图背景图像 */
.background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 1rem;
}
.dimmed-image {
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.5);
opacity: 0.5;
}
.error-placeholder {
background: #1f2937;
}
.error-icon {
font-size: 2rem;
color: #ef4444;
margin-bottom: 1rem;
}
.cancel-placeholder {
background: #1f2937;
}
.cancel-icon {
font-size: 2rem;
color: #f59e0b;
margin-bottom: 1rem;
}
.loading-spinner {
font-size: 2rem;
color: #8b5cf6;
margin-bottom: 1rem;
}
.loading-text {
color: #9ca3af;
font-size: 0.875rem;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 信息区域 */
.info-section {
display: flex;
align-items: center;
justify-content: center;
}
.info-content {
max-width: 500px;
padding: 2rem 0;
}
/* 标题容器 */
.title-container {
text-align: center;
margin-bottom: 2rem;
}
.main-title {
font-size: 3rem;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.progress-status {
font-size: 0.75rem;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
}
.main-description {
font-size: 1.25rem;
color: #d1d5db;
margin-bottom: 2.5rem;
line-height: 1.6;
}
/* 进度条样式 */
.progress-section {
margin-bottom: 2rem;
}
.subtask-progress {
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 0.75rem;
}
.progress-header {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 0.75rem;
}
.progress-status {
font-size: 0.75rem;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
}
.progress-container {
margin-top: 0.75rem;
}
.minimal-progress-bar {
margin-bottom: 0.75rem;
}
.progress-line {
position: relative;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.1);
border-radius: 1px;
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #a855f7);
border-radius: 1px;
transition: width 0.5s ease;
}
.moving-dot {
position: absolute;
top: -4px;
width: 10px;
height: 10px;
background: linear-gradient(45deg, #8b5cf6, #a855f7);
border-radius: 50%;
box-shadow: 0 0 10px rgba(139, 92, 246, 0.6);
transition: left 0.5s ease;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.6);
}
50% {
transform: scale(1.2);
box-shadow: 0 0 15px rgba(139, 92, 246, 0.8);
}
}
.progress-info {
display: flex;
justify-content: center;
font-size: 0.75rem;
color: #9ca3af;
}
.queue-info {
margin-top: 0.75rem;
text-align: center;
}
.queue-visualization {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.queue-people {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.queue-person {
font-size: 0.875rem;
color: #f59e0b;
opacity: 0.8;
}
.queue-more {
font-size: 0.75rem;
color: #f59e0b;
font-weight: 600;
margin-left: 0.25rem;
}
.queue-text {
font-size: 0.75rem;
color: #f59e0b;
font-weight: 500;
}
.estimated-wait-time {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #22c55e;
font-weight: 600;
animation: countdown 1s ease-in-out infinite;
}
.estimated-time {
display: flex;
align-items: center;
font-size: 0.875rem;
color: #22c55e;
font-weight: 600;
animation: countdown 1s ease-in-out infinite;
}
@keyframes countdown {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
/* 特性列表 */
.features-list {
margin-bottom: 2.5rem;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 0;
}
.feature-icon {
width: 40px;
height: 40px;
background: rgba(139, 92, 246, 0.1);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
color: #8b5cf6;
font-size: 1.125rem;
}
.feature-text {
font-size: 1rem;
color: #e5e7eb;
font-weight: 500;
}
/* 操作按钮 */
.action-buttons {
margin-bottom: 2rem;
}
.primary-button {
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
border: none;
border-radius: 0.75rem;
color: white;
font-size: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.primary-button:hover {
background: linear-gradient(135deg, #7c3aed, #9333ea);
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(139, 92, 246, 0.4);
}
.secondary-button {
width: 100%;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
color: white;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.delete-button {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.delete-button:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
/* 技术信息 */
.tech-info {
text-align: center;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.tech-text {
color: #9ca3af;
font-size: 0.875rem;
font-weight: 500;
}
.tech-link {
color: #9ca3af;
text-decoration: underline;
transition: color 0.3s ease;
}
.tech-link:hover {
color: #8b5cf6;
}
/* 详细信息面板 */
.details-panel {
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
padding: 5rem 0;
}
.details-content {
width: 100%;
margin: 0 auto;
padding: 0 2rem;
}
/* 输入素材标题 */
.materials-header {
text-align: center;
margin-bottom: 2rem;
}
.materials-title {
font-size: 1.5rem;
font-weight: 600;
color: white;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
/* 三个并列的卡片 */
.materials-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.material-card {
background: rgba(255, 255, 255, 0.08);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.material-card:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(139, 92, 246, 0.3);
transform: translateY(-2px);
}
.card-header {
background: rgba(139, 92, 246, 0.1);
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 0.75rem;
position: relative;
}
.card-icon {
font-size: 1.25rem;
color: #8b5cf6;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: white;
margin: 0;
flex: 1;
}
.card-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.download-btn {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.download-btn:hover {
background: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.5);
transform: scale(1.05);
}
.copy-btn {
background: rgba(107, 114, 128, 0.2);
color: #9ca3af;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.copy-btn:hover {
background: rgba(107, 114, 128, 0.3);
border-color: rgba(107, 114, 128, 0.5);
transform: scale(1.05);
}
.card-content {
padding: 1.5rem;
min-height: 200px;
}
/* 图片网格 */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.image-item {
text-align: center;
}
.image-container {
position: relative;
width: 100%;
min-height: 120px;
margin-bottom: 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
}
.image-preview {
max-width: 100%;
min-height: 80px;
height: auto;
width: auto;
object-fit: contain;
display: block;
position: relative !important;
}
/* 音频列表 */
.audio-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.audio-item {
text-align: center;
}
.audio-player {
width: 100%;
height: 40px;
margin-bottom: 0.5rem;
}
/* 提示词内容 */
.prompt-content {
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.prompt-text {
color: #d1d5db;
line-height: 1.6;
margin: 0;
word-break: break-word;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
color: #6b7280;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.empty-text {
font-size: 0.875rem;
margin: 0;
opacity: 0.7;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.content-grid {
gap: 3rem;
padding: 0 1.5rem;
}
.main-title {
font-size: 2.5rem;
}
.video-container {
max-width: 400px;
}
/* 卡片响应式 */
.materials-cards {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
@media (max-width: 768px) {
.main-content {
padding: 1rem 0;
}
.content-grid {
grid-template-columns: 1fr;
gap: 2rem;
padding: 0 1rem;
}
.main-title {
font-size: 2rem;
}
.main-description {
font-size: 1.125rem;
}
.video-container {
max-width: 300px;
}
.info-content {
padding: 1rem 0;
}
.details-content {
padding: 0 1rem;
}
/* 移动端卡片调整 */
.materials-cards {
gap: 1rem;
}
.card-content {
padding: 1rem;
min-height: 150px;
}
.image-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.materials-title {
font-size: 1.25rem;
}
/* 移动端进度条调整 */
.subtask-progress {
padding: 0.75rem;
margin-bottom: 1rem;
}
.progress-info {
flex-direction: column;
gap: 0.5rem;
}
}
</style>
0<script setup>
import { showTemplateDetailModal,
closeTemplateDetailModal,
useTemplate,
getTemplateFileUrl,
onVideoLoaded,
selectedTemplate,
applyTemplateAudio,
applyTemplateImage,
applyTemplatePrompt,
showImageZoom,
copyPrompt,
generateTemplateShareUrl,
copyShareLink,
shareTemplateToSocial,
} from '../utils/other'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
// 添加响应式变量
const showDetails = ref(false)
// 获取图片素材
const getImageMaterials = () => {
if (!selectedTemplate.value?.inputs?.input_image) return []
return [['input_image', getTemplateFileUrl(selectedTemplate.value.inputs.input_image, 'images')]]
}
// 获取音频素材
const getAudioMaterials = () => {
if (!selectedTemplate.value?.inputs?.input_audio) return []
return [['input_audio', getTemplateFileUrl(selectedTemplate.value.inputs.input_audio, 'audios')]]
}
// 路由关闭功能
const closeWithRoute = () => {
closeTemplateDetailModal()
// 如果是从路由进入的,可以返回到上一页
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/')
}
}
// 键盘事件处理
const handleKeydown = (event) => {
if (event.key === 'Escape' && showTemplateDetailModal.value) {
closeWithRoute()
}
}
// 生命周期钩子
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<!-- 模板详情弹窗 -->
<div v-cloak>
<div v-if="showTemplateDetailModal"
class="fixed inset-0 bg-dark-500 z-50 flex items-center justify-center p-4"
@click="closeWithRoute">
<div class="landing-page" @click.stop>
<!-- 弹窗头部 -->
<div class="modal-header">
<h3 class="modal-title">
<i class="fas fa-star mr-2"></i>
{{ t('templateDetail') }}
</h3>
<div class="header-actions">
<button @click="closeWithRoute" class="action-button back-button" :title="t('back')">
<i class="fas fa-arrow-left"></i>
</button>
<button @click="closeWithRoute" class="action-button close-button" :title="t('close')">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content main-scrollbar overflow-y-auto">
<!-- 分享内容 -->
<div class="content-grid">
<!-- 左侧视频区域 -->
<div class="video-section">
<div class="video-container">
<!-- 视频播放器 -->
<video
v-if="selectedTemplate?.outputs?.output_video"
:src="getTemplateFileUrl(selectedTemplate.outputs.output_video,'videos')"
:poster="getTemplateFileUrl(selectedTemplate.inputs.input_image,'images')"
class="video-player"
controls
autoplay
loop
preload="metadata"
@loadstart="onVideoLoadStart"
@canplay="onVideoCanPlay"
@error="onVideoError">
{{ t('browserNotSupported') }}
</video>
<div v-else class="video-placeholder">
<div class="loading-spinner">
<i class="fas fa-video"></i>
</div>
<p class="loading-text">{{ t('videoNotAvailable') }}</p>
</div>
</div>
</div>
<!-- 右侧信息区域 -->
<div class="info-section">
<div class="info-content">
<!-- 标题 -->
<h1 class="main-title">
{{ t('template') }}
</h1>
<!-- 描述 -->
<p class="main-description">
{{ t('templateDescription') }}
</p>
<!-- 特性列表 -->
<div class="features-list justify-between">
<div class="feature-item cursor-pointer" @click="applyTemplateImage(selectedTemplate)">
<i class="fas fa-image feature-icon"></i>
<span>{{ t('onlyUseImage') }}</span>
</div>
<div class="feature-item cursor-pointer" @click="applyTemplateAudio(selectedTemplate)">
<i class="fas fa-music feature-icon"></i>
<span>{{ t('onlyUseAudio') }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<button @click="useTemplate(selectedTemplate)" class="primary-button">
<i class="fas fa-magic mr-2"></i>
{{ t('useTemplate') }}
</button>
<button @click="copyShareLink(selectedTemplate?.task_id, 'template')" class="secondary-button">
<i class="fas fa-share-alt mr-2"></i>
{{ t('shareTemplate') }}
</button>
<button @click="showDetails = !showDetails" class="secondary-button">
<i :class="showDetails ? 'fas fa-chevron-up' : 'fas fa-info-circle'" class="mr-2"></i>
{{ showDetails ? t('hideDetails') : t('showDetails') }}
</button>
</div>
<!-- 技术信息 -->
<div class="tech-info">
<p class="tech-text">{{ t('poweredByLightX2V') }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- 详细信息面板 -->
<div v-if="showDetails && selectedTemplate" class="details-panel">
<div class="details-content">
<!-- 输入素材标题 -->
<div class="materials-header">
<h2 class="materials-title">
<i class="fas fa-upload mr-2"></i>
{{ t('inputMaterials') }}
</h2>
</div>
<!-- 三个并列的分块卡片 -->
<div class="materials-cards">
<!-- 图片卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-image card-icon"></i>
<h3 class="card-title">{{ t('image') }}</h3>
<div class="card-actions">
<button v-if="selectedTemplate?.inputs?.input_image"
@click="applyTemplateImage(selectedTemplate)"
class="action-btn use-btn"
:title="t('applyImage')">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="getImageMaterials().length > 0" class="image-grid">
<div v-for="[inputName, url] in getImageMaterials()" :key="inputName" class="image-item">
<div class="image-container" @click="showImageZoom(url)">
<img :src="url" :alt="inputName" class="image-preview">
</div>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-image empty-icon"></i>
<p class="empty-text">{{ t('noImage') }}</p>
</div>
</div>
</div>
<!-- 音频卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-music card-icon"></i>
<h3 class="card-title">{{ t('audio') }}</h3>
<div class="card-actions">
<button v-if="selectedTemplate?.inputs?.input_audio"
@click="applyTemplateAudio(selectedTemplate)"
class="action-btn use-btn"
:title="t('applyAudio')">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="getAudioMaterials().length > 0" class="audio-list">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName" class="audio-item">
<audio :src="url" controls class="audio-player"></audio>
</div>
</div>
<div v-else class="empty-state">
<i class="fas fa-music empty-icon"></i>
<p class="empty-text">{{ t('noAudio') }}</p>
</div>
</div>
</div>
<!-- 提示词卡片 -->
<div class="material-card">
<div class="card-header">
<i class="fas fa-file-alt card-icon"></i>
<h3 class="card-title">{{ t('prompt') }}</h3>
<div class="card-actions">
<button v-if="selectedTemplate?.params?.prompt"
@click="copyPrompt(selectedTemplate?.params?.prompt)"
class="action-btn copy-btn"
:title="t('copy')">
<i class="fas fa-copy"></i>
</button>
<button v-if="selectedTemplate?.params?.prompt"
@click="applyTemplatePrompt(selectedTemplate)"
class="action-btn use-btn"
:title="t('applyPrompt')">
<i class="fas fa-magic"></i>
</button>
</div>
</div>
<div class="card-content">
<div v-if="selectedTemplate?.params?.prompt" class="prompt-content">
<p class="prompt-text">{{ selectedTemplate.params.prompt }}</p>
</div>
<div v-else class="empty-state">
<i class="fas fa-file-alt empty-icon"></i>
<p class="empty-text">{{ t('noPrompt') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Landing Page 样式 */
.landing-page {
min-height: calc(100vh - 60px);
width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
color: white;
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: white;
margin: 0;
display: flex;
align-items: center;
}
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.action-button {
background: none;
border: none;
color: #9ca3af;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
padding: 0.5rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.action-button:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.back-button:hover {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.close-button:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.main-content {
width: 100%;
padding: 2rem 0;
min-height: calc(100vh - 80px);
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
}
/* 内容网格布局 */
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
width: 100%;
margin: 0 auto;
padding: 0 2rem;
align-items: center;
min-height: 60vh;
}
/* 视频区域 */
.video-section {
display: flex;
justify-content: center;
align-items: center;
}
.video-container {
width: 100%;
max-width: 500px;
aspect-ratio: 9/16;
background: #000;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
position: relative;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #1f2937;
}
.loading-spinner {
font-size: 2rem;
color: #8b5cf6;
margin-bottom: 1rem;
}
.loading-text {
color: #9ca3af;
font-size: 0.875rem;
}
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
/* 信息区域 */
.info-section {
display: flex;
align-items: center;
justify-content: center;
}
.info-content {
max-width: 500px;
padding: 2rem 0;
}
.main-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.2;
}
.main-description {
font-size: 1.25rem;
color: #d1d5db;
margin-bottom: 2.5rem;
line-height: 1.6;
}
/* 特性列表 */
.features-list {
margin-bottom: 2.5rem;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 0;
}
.feature-icon {
width: 40px;
height: 40px;
background: rgba(139, 92, 246, 0.1);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
color: #8b5cf6;
font-size: 1.125rem;
}
.feature-text {
font-size: 1rem;
color: #e5e7eb;
font-weight: 500;
}
/* 操作按钮 */
.action-buttons {
margin-bottom: 2rem;
}
.primary-button {
width: 100%;
padding: 1rem 2rem;
background: linear-gradient(135deg, #8b5cf6, #a855f7);
border: none;
border-radius: 0.75rem;
color: white;
font-size: 1.125rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.primary-button:hover {
background: linear-gradient(135deg, #7c3aed, #9333ea);
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(139, 92, 246, 0.4);
}
.secondary-button {
width: 100%;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
color: white;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
/* 技术信息 */
.tech-info {
text-align: center;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.tech-text {
color: #9ca3af;
font-size: 0.875rem;
font-weight: 500;
}
/* 详细信息面板 */
.details-panel {
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
padding: 5rem 0;
}
.details-content {
width: 100%;
margin: 0 auto;
padding: 0 2rem;
}
/* 输入素材标题 */
.materials-header {
text-align: center;
margin-bottom: 2rem;
}
.materials-title {
font-size: 1.5rem;
font-weight: 600;
color: white;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
/* 三个并列的卡片 */
.materials-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.material-card {
background: rgba(255, 255, 255, 0.08);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.material-card:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(139, 92, 246, 0.3);
transform: translateY(-2px);
}
.card-header {
background: rgba(139, 92, 246, 0.1);
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 0.75rem;
position: relative;
}
.card-icon {
font-size: 1.25rem;
color: #8b5cf6;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: white;
margin: 0;
flex: 1;
}
.card-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.action-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
}
.use-btn {
background: rgba(139, 92, 246, 0.2);
color: #8b5cf6;
border: 1px solid rgba(139, 92, 246, 0.3);
}
.use-btn:hover {
background: rgba(139, 92, 246, 0.3);
border-color: rgba(139, 92, 246, 0.5);
transform: scale(1.05);
}
.copy-btn {
background: rgba(107, 114, 128, 0.2);
color: #9ca3af;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.copy-btn:hover {
background: rgba(107, 114, 128, 0.3);
border-color: rgba(107, 114, 128, 0.5);
transform: scale(1.05);
}
.card-content {
padding: 1.5rem;
min-height: 200px;
}
/* 图片网格 */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.image-item {
text-align: center;
}
.image-container {
position: relative;
width: 100%;
min-height: 120px;
margin-bottom: 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.image-container:hover {
transform: scale(1.05);
border-color: rgba(139, 92, 246, 0.3);
}
.image-preview {
max-width: 100%;
min-height: 80px;
height: auto;
width: auto;
object-fit: contain;
display: block;
position: relative !important;
}
/* 音频列表 */
.audio-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.audio-item {
text-align: center;
}
.audio-player {
width: 100%;
height: 40px;
margin-bottom: 0.5rem;
}
/* 提示词内容 */
.prompt-content {
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.prompt-text {
color: #d1d5db;
line-height: 1.6;
margin: 0;
word-break: break-word;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
color: #6b7280;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.empty-text {
font-size: 0.875rem;
margin: 0;
opacity: 0.7;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.content-grid {
gap: 3rem;
padding: 0 1.5rem;
}
.main-title {
font-size: 2.5rem;
}
.video-container {
max-width: 400px;
}
/* 卡片响应式 */
.materials-cards {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
@media (max-width: 768px) {
.main-content {
padding: 1rem 0;
}
.content-grid {
grid-template-columns: 1fr;
gap: 2rem;
padding: 0 1rem;
}
.main-title {
font-size: 2rem;
}
.main-description {
font-size: 1.125rem;
}
.video-container {
max-width: 300px;
}
.info-content {
padding: 1rem 0;
}
.details-content {
padding: 0 1rem;
}
/* 移动端卡片调整 */
.materials-cards {
gap: 1rem;
}
.card-content {
padding: 1rem;
min-height: 150px;
}
.image-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.materials-title {
font-size: 1.25rem;
}
}
</style>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Props
const props = defineProps({
templates: {
type: Array,
default: () => []
},
showActions: {
type: Boolean,
default: true
},
layout: {
type: String,
default: 'grid', // 'grid' 或 'waterfall'
validator: (value) => ['grid', 'waterfall'].includes(value)
},
columns: {
type: Number,
default: 2
},
maxTemplates: {
type: Number,
default: 10
}
})
// 从 utils/other 导入需要的函数
import {
getTemplateFileUrl,
handleThumbnailError,
playVideo,
pauseVideo,
toggleVideoPlay,
onVideoLoaded,
onVideoError,
onVideoEnded,
previewTemplateDetail,
useTemplate,
applyTemplateImage,
applyTemplateAudio,
copyShareLink
} from '../utils/other'
// 屏幕尺寸响应式状态
const screenSize = ref('large')
// 更新屏幕尺寸
const updateScreenSize = () => {
screenSize.value = window.innerWidth >= 1024 ? 'large' : 'small'
}
// 随机列布局相关函数(用于网格布局)
const generateRandomColumnLayout = (templates) => {
if (!templates || templates.length === 0) return { columns: [], templates: [] }
const numColumns = props.columns
// 生成随机列宽(总和为100%)
const columnWidths = []
let remainingWidth = 100
for (let i = 0; i < numColumns; i++) {
if (i === numColumns - 1) {
columnWidths.push(remainingWidth)
} else {
const minWidth = 20
const maxWidth = Math.min(50, remainingWidth - (numColumns - i - 1) * minWidth)
const width = Math.random() * (maxWidth - minWidth) + minWidth
columnWidths.push(Math.round(width))
remainingWidth -= Math.round(width)
}
}
// 生成每列的起始位置
const columnStartPositions = []
for (let i = 0; i < numColumns; i++) {
const startPosition = Math.random() * 20
columnStartPositions.push(Math.round(startPosition))
}
// 计算每列的起始left位置
const columnLeftPositions = []
let currentLeft = 0
for (let i = 0; i < numColumns; i++) {
columnLeftPositions.push(currentLeft)
currentLeft += columnWidths[i]
}
// 将模版分配到各列
const columnTemplates = Array.from({ length: numColumns }, () => [])
templates.forEach((template, index) => {
const columnIndex = index % numColumns
columnTemplates[columnIndex].push(template)
})
// 生成列配置
const columns = columnWidths.map((width, index) => ({
width: `${width}%`,
left: `${columnLeftPositions[index]}%`,
top: `${columnStartPositions[index]}%`,
templates: columnTemplates[index]
}))
return { columns, templates }
}
// 计算属性:带随机列布局的模版
const templatesWithRandomColumns = computed(() => {
if (props.layout === 'waterfall') {
return { columns: [], templates: props.templates }
}
return generateRandomColumnLayout(props.templates)
})
// 组件挂载时初始化
onMounted(() => {
updateScreenSize()
window.addEventListener('resize', updateScreenSize)
})
</script>
<template>
<div class="template-display">
<!-- 瀑布流布局 -->
<div v-if="layout === 'waterfall'" class="waterfall-layout">
<div class="columns-2 md:columns-2 lg:columns-3 xl:columns-3 gap-4">
<div v-for="item in templates" :key="item.task_id"
class="break-inside-avoid mb-4 group relative bg-dark-light/50 rounded-xl overflow-hidden border border-gray-700/30 hover:border-laser-purple/50 transition-all duration-300 hover:shadow-laser/30 hover:bg-dark-light/70">
<!-- 视频缩略图区域 -->
<div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="showActions ? previewTemplateDetail(item) : null"
:title="showActions ? t('viewTemplateDetail') : ''">
<!-- 视频预览 -->
<video v-if="item?.outputs?.output_video"
:src="getTemplateFileUrl(item.outputs.output_video,'videos')"
:poster="getTemplateFileUrl(item.inputs.input_image,'images')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 图片缩略图 -->
<img v-else
:src="getTemplateFileUrl(item.inputs.input_image,'images')"
:alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" />
<!-- 移动端播放按钮 -->
<button v-if="item?.outputs?.output_video"
@click.stop="toggleVideoPlay($event)"
class="md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 悬浮操作按钮(仅当 showActions 为 true 时显示) -->
<div v-if="showActions"
class="hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full">
<div class="flex space-x-2 pointer-events-auto">
<button @click.stop="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyImage')">
<i class="fas fa-image text-sm"></i>
</button>
<button @click.stop="applyTemplateAudio(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyAudio')">
<i class="fas fa-music text-sm"></i>
</button>
<button @click.stop="useTemplate(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('useTemplate')">
<i class="fas fa-clone text-sm"></i>
</button>
<button @click.stop="copyShareLink(item.task_id, 'template')"
class="w-10 h-10 rounded-full bg-blue-500 backdrop-blur-sm flex items-center justify-center text-white hover:bg-blue-600 transition-colors"
:title="t('shareTemplate')">
<i class="fas fa-share-alt text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 网格布局 -->
<div v-else class="grid-layout">
<div class="relative min-h-[400px] lg:min-h-[600px]">
<!-- 随机列 -->
<div v-for="(column, columnIndex) in templatesWithRandomColumns.columns" :key="columnIndex"
class="absolute transition-all duration-500 animate-fade-in"
:style="{
width: column.width,
left: column.left,
top: column.top,
animationDelay: `${columnIndex * 0.2}s`
}">
<!-- 列内的模版卡片 -->
<div v-for="item in column.templates" :key="item.task_id"
class="mb-3 group relative bg-dark-light rounded-xl overflow-hidden border border-gray-700/50 hover:border-laser-purple/40 transition-all duration-300 hover:shadow-laser/20">
<!-- 视频缩略图区域 -->
<div class="cursor-pointer bg-gray-800 relative flex flex-col"
@click="showActions ? previewTemplateDetail(item) : null"
:title="showActions ? t('viewTemplateDetail') : ''">
<!-- 视频预览 -->
<video v-if="item?.outputs?.output_video"
:src="getTemplateFileUrl(item.outputs.output_video,'videos')"
:poster="getTemplateFileUrl(item.inputs.input_image,'images')"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 图片缩略图 -->
<img v-else
:src="getTemplateFileUrl(item.inputs.input_image,'images')"
:alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-105 transition-transform duration-300"
@error="handleThumbnailError" />
<!-- 移动端播放按钮 -->
<button v-if="item?.outputs?.output_video"
@click.stop="toggleVideoPlay($event)"
class="md:hidden absolute bottom-3 left-1/2 transform -translate-x-1/2 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/70 transition-colors z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 悬浮操作按钮(仅当 showActions 为 true 时显示) -->
<div v-if="showActions"
class="hidden md:flex absolute bottom-3 left-1/2 transform -translate-x-1/2 items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10 w-full">
<div class="flex space-x-3 pointer-events-auto">
<button @click.stop="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyImage')">
<i class="fas fa-image text-sm"></i>
</button>
<button @click.stop="applyTemplateAudio(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('applyAudio')">
<i class="fas fa-music text-sm"></i>
</button>
<button @click.stop="useTemplate(item)"
class="w-10 h-10 rounded-full bg-laser-purple backdrop-blur-sm flex items-center justify-center text-white hover:bg-laser-purple transition-colors"
:title="t('useTemplate')">
<i class="fas fa-clone text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.template-display {
width: 100%;
}
.waterfall-layout {
width: 100%;
}
.grid-layout {
width: 100%;
}
/* 动画效果 */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.6s ease-out forwards;
}
</style>
<script setup>
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t, locale } = useI18n()
const router = useRouter()
import { initLanguage,loadLanguageAsync, switchLang, languageOptions } from '../utils/i18n'
import {
currentUser,
logout,
showTemplateDetailModal,
showTaskDetailModal,
login,
} from '../utils/other'
// 导航到主页面
const goToHome = () => {
showTemplateDetailModal.value = false
showTaskDetailModal.value = false
router.push({ name: 'Generate' })
}
</script>
<template>
<!-- 顶部栏 -->
<div class="top-bar">
<div class="top-bar-content">
<div class="top-bar-left">
<button @click="goToHome" class="logo-button" :title="t('goToHome')">
<i class="fas fa-film text-gradient-primary mr-2 text-xl"></i>
<span class="text-lg text-white">LightX2V</span>
</button>
</div>
<!-- 右侧用户信息 -->
<div class="top-bar-right">
<!-- 语言切换按钮 -->
<div class="language-switcher mr-6">
<button @click="switchLang"
class="w-10 h-10 text-gradient-primary items-center px-1 py-1
rounded-lg bg-laser-purple border border-laser-purple hover:border-laser-purple
transition-all duration-200 text-sm hover:scale-105"
:title="t('switchLanguage')">
<span class="text-lg">{{ languageOptions.find(lang => lang.code ===
(locale === 'zh' ? 'en' : 'zh'))?.flag }}</span>
</button>
</div>
<div class="user-info">
<div>
<avatar v-if="currentUser.avatar" :src="getUserAvatarUrl(currentUser)"
:alt="currentUser.username" class="size-10">
</avatar>
<i v-else class="fi fi-rr-circle-user text-2xl"></i>
</div>
<div class="user-details">
<span v-if="currentUser">
{{ currentUser.username || currentUser.email || '用户' }}
</span>
<span v-else>未登录</span>
</div>
<div v-if="currentUser.username">
<button @click="logout" class="text-gradient-primary" :title="t('logout')">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
<div v-else>
<button @click="login" class="text-gradient-primary" :title="t('login')">
<i class="fas fa-sign-in-alt"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.logo-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
transition: all 0.2s ease;
border-radius: 0.5rem;
padding: 0.5rem;
}
.logo-button:hover {
background: rgba(139, 92, 246, 0.1);
transform: scale(1.05);
}
.logo-button:active {
transform: scale(0.95);
}
</style>
{
"total": "Total",
"tasks": "tasks",
"records": "records",
"goToHome": "Go to Home",
"imageTemplates": "Image Templates",
"audioTemplates": "Audio Templates",
"noImageTemplates": "No image templates",
"noAudioTemplates": "No audio templates",
"templateDetail": "Template detail",
"viewTemplateDetail": "View Template Detail",
"viewTaskDetails": "View Task Details",
"templateInfo": "Template Info",
"model": "Model",
"type": "Type",
"inputMaterials": "Input Materials",
"inputImage": "Input Image",
"inputAudio": "Input Audio",
"optional": "(Optional)",
"pageTitle": "LightX2V Service",
"pleaseEnterThePromptForVideoGeneration": "Please enter the prompt for video generation",
"describeTheContentStyleSceneOfTheVideo": "Describe the content, style, and scene of the video...",
"describeTheDigitalHumanImageBackgroundStyleActionRequirements": "Describe the digital human expression, tone, and action...",
"describeTheContentActionRequirementsBasedOnTheImage": "Describe the content and action requirements based on the image...",
"loginSubtitle": "A powerful video generation platform",
"whatDoYouWantToDo": "What do you want to do today?",
"whatMaterialsDoYouNeed": "What materials do you need to create a video?",
"pleaseEnterTheMostDetailedVideoScript": "Please enter the most detailed video script",
"pleaseUploadAnImageAsTheFirstFrameOfTheVideoAndTheMostDetailedVideoScript": "Please upload an image as the first frame of the video and the most detailed video script",
"pleaseUploadARoleImageAnAudioAndTheGeneralVideoRequirements": "Please upload a role image, an audio, and the general video requirements",
"collapseCreationArea": "Collapse creation area",
"startCreatingVideo": "Start creating video···",
"loginWithGitHub": "GitHub",
"loginWithGoogle": "Google",
"loginWithSMS": "SMS",
"loggingIn": "Logging in...",
"logout": "Logout",
"loggedOut": "Logged out",
"loginFailed": "Login failed",
"loginError": "Error occurred during login",
"authFailed": "Authentication failed, please login again",
"loginExpired": "Login expired, please login again",
"orLoginWith": "Or login with",
"login": "Login / Register",
"loginLoading": "Logging in···",
"sendSmsCode": "Send SMS",
"phoneNumber": "Phone Number",
"verifyCode": "Verify Code",
"feature1": "Cinema-grade digital human videos",
"feature2": "20x faster generation",
"feature3": "Ultra-low cost generation",
"feature4": "Precise lip-sync alignment",
"feature5": "Minute-level video duration",
"feature6": "Multi-scenario applications",
"generateVideo": "Generate Video",
"history": "History",
"inspiration": "Inspiration",
"discoverCreativity": "Discover creativity, inspire ideas",
"searchTasks": "Search history tasks...",
"searchInspiration": "Search inspiration...",
"refresh": "Refresh task list",
"noHistoryTasks": "No history tasks",
"startToCreateYourFirstAIVideo": "Start creating your first AI video",
"switchLanguage": "Switch Language",
"selectTaskType": "Select Task Type",
"selectTaskTypeFirst": "Please select task type first",
"noHistoryRecords": "No history records",
"imageHistoryAutoSave": "Image history will be automatically saved when you start using images",
"audioHistoryAutoSave": "Audio history will be automatically saved when you start using audio",
"clearHistory": "Clear history",
"clear": "Clear",
"promptHistoryAutoSave": "Prompt history will be automatically saved when you start creating tasks",
"promptTip": "Describe the video content you want in detail",
"viewFailureReason": "View Failure Reason",
"failureReason": "Failure Reason",
"noPrompt": "No Prompt",
"uploadMaterials": "Upload Materials",
"image": "Image",
"audioFile": "Audio File",
"myProjects": "My Projects",
"initializationFailed": "Initialization Failed, Please Refresh The Page",
"browserNotSupported": "Browser Not Supported",
"videoLoadFailed": "Video Load Failed",
"loadingVideo": "Loading Video···",
"videoGenerating": "Video Generating",
"taskProgress": "Task Progress",
"subtask": "Subtask",
"queuePosition": "Waiting for",
"availableWorker": "Available Worker",
"videoGeneratingFailed": "Video Generating Failed",
"sorryYourVideoGenerationTaskFailed": "Sorry Your Video Generation Task Failed",
"thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore": "This Task Has Been Cancelled You Can Regenerate Or View The Materials You Uploaded Before",
"taskCompleted": "Task Completed",
"taskFailed": "Task Failed",
"taskCancelled": "Cancelled",
"taskRunning": "Task Running",
"taskPending": "Task Pending",
"taskInfo": "Task Info",
"taskID": "Task ID",
"modelName": "Model Name",
"createTime": "Create Time",
"updateTime": "Update Time",
"aiIsGeneratingYourVideo": "LightX2V is generating your video...",
"taskSubmittedSuccessfully": "Task submitted successfully, accelerating processing...",
"taskQueuePleaseWait": "The task is a little bit, accelerating queueing...",
"success": "Success",
"failed": "Failed",
"pending": "Waiting",
"cancelled": "Cancelled",
"all": "All",
"created": "Created",
"status": "Status",
"reuseTask": "Reuse",
"regenerateTask": "Retry",
"retryTask": "Retry",
"downloadTask": "Download Video",
"downloadVideo": "Download Video",
"deleteTask": "Delete",
"cancelTask": "Cancel",
"download": "Download",
"createVideo": "Create Video",
"selectTemplate": "Select Template",
"uploadImage": "Upload Image",
"uploadAudio": "Upload Audio",
"recordAudio": "Record Audio",
"recording": "Recording...",
"takePhoto": "Take Photo",
"retake": "Retake",
"usePhoto": "Use Photo",
"upload": "Upload",
"stopRecording": "Stop Recording",
"recordingStarted": "Recording started",
"recordingStopped": "Recording stopped",
"recordingCompleted": "Recording completed",
"recordingFailed": "Recording failed",
"enterPrompt": "Enter Prompt",
"selectModel": "Select Model",
"startGeneration": "Start Generation",
"templates": "Templates",
"useTemplate": "Use Template",
"applyImage": "Apply Image",
"applyAudio": "Apply Audio",
"featuredTemplates": "Featured Templates",
"discoverFeaturedCreativity": "Discover Featured Creativity",
"refreshRandomTemplates": "Refresh Random Templates",
"discover": "Discover",
"viewMoreTemplates": "View More Templates",
"searchTemplates": "Search Templates",
"browseCategories": "Browse Categories",
"inspirationGallery": "Inspirations",
"viewMore": "View More",
"more": "More",
"applyPrompt": "Apply Prompt",
"imageApplied": "Image applied",
"audioApplied": "Audio applied",
"promptApplied": "Prompt applied",
"copy": "Copy",
"promptCopied": "Prompt copied to clipboard",
"outputVideo": "Output Video",
"textToVideo": "Text to Video",
"imageToVideo": "Image to Video",
"speechToVideo": "Speech to Video",
"prompt": "Prompt",
"negativePrompt": "Negative Prompt",
"promptTemplates": "Prompt Templates",
"promptHistory": "Prompt History",
"t2vHint1": "Enter text description, AI will generate精彩的视频内容",
"t2vHint2": "Support multiple styles: realistic, animation, art, etc.",
"t2vHint3": "Can describe scenes, actions, emotions, etc.",
"t2vHint4": "Let your creativity become a vivid video",
"i2vHint1": "Upload an image, AI will generate dynamic video",
"i2vHint2": "Support multiple image formats: JPG, PNG, WebP, etc.",
"i2vHint3": "Can generate various dynamic effects.",
"i2vHint4": "Let static image become dynamic, create infinite possibilities",
"s2vHint1": "Upload a role image + an audio",
"s2vHint2": "AI will make the role speak and move according to the audio content.",
"s2vHint3": "Let your role become alive.",
"s2vHint4": "Create your own digital person.",
"uploadImageFile": "Upload Image File",
"uploadAudioFile": "Upload Audio File",
"dragDropHere": "Drag and drop files here or click to upload",
"supportedImageFormats": "Supported jpg, png, webp image formats (< 10MB)",
"clearCharacterImageTip": "Upload a clear character image",
"maxFileSize": "Max file size",
"taskDetails": "Task Details",
"taskId": "Task ID",
"taskType": "Task Type",
"taskStatus": "Task Status",
"createdAt": "Created At",
"completedAt": "Completed At",
"duration": "Duration",
"confirm": "Confirm",
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
"delete": "Delete",
"close": "Close",
"back": "Back",
"next": "Next",
"previous": "Previous",
"finish": "Finish",
"submitting": "Submitting...",
"operationSuccess": "Operation successful",
"operationFailed": "Operation failed",
"pleaseWait": "Please wait...",
"loading": "Loading···",
"noData": "No data",
"errorOccurred": "Error occurred",
"networkError": "Network error",
"serverError": "Server error",
"seconds": "seconds",
"deleteTaskConfirm": "Delete Task?",
"deleteTaskConfirmMessage": "This action cannot be undone. It will delete the task record, generated files, and related data.",
"confirmDelete": "Delete",
"regenerateTaskConfirm": "Regenerate Task?",
"regenerateTaskConfirmMessage": "Regenerating will delete the current task and generated content, then create a new task with the same parameters. This action cannot be undone!",
"confirmRegenerate": "Regenerate",
"regeneratingTaskAlert": "Regenerating task...",
"deletingTaskAlert": "Deleting task...",
"taskDeletedSuccessAlert": "Task deleted successfully",
"deleteTaskFailedAlert": "Delete task failed",
"getTaskDetailFailedAlert": "Get task detail failed",
"taskNotExistAlert": "Task not exist",
"loadTaskFilesFailedAlert": "Load task files failed",
"taskMaterialReuseSuccessAlert": "Task material reuse successfully",
"loadTaskDataFailedAlert": "Load task data failed",
"fileUnavailableAlert": "File unavailable",
"downloadFailedAlert": "Download failed",
"taskSubmitSuccessAlert": "Task submit successfully",
"taskSubmitFailedAlert": "Task submit failed",
"submitTaskFailedAlert": "Submit task failed",
"fileDownloadSuccessAlert": "File download successfully",
"getTaskResultFailedAlert": "Get task result failed",
"downloadTaskResultFailedAlert": "Download task result failed",
"viewTaskResultFailedAlert": "View task result failed",
"cancelTaskConfirm": "Cancel task?",
"cancelTaskConfirmMessage": "Cancel task will stop the task execution, and the generated part of the result may be lost, can be regenerated later.",
"confirmCancel": "Cancel",
"taskCancelSuccessAlert": "Task cancel successfully",
"cancelTaskFailedAlert": "Cancel task failed",
"taskRetrySuccessAlert": "Task retry successfully",
"retryTaskFailedAlert": "Retry task failed",
"taskRegenerateSuccessAlert": "Task regenerated successfully",
"regenerateTaskFailedAlert": "Regenerate task failed",
"taskNotFoundAlert": "Task not found",
"templateApplied": "Template applied",
"shareTemplate": "Share",
"copyShareLink": "Copy Link",
"promptHistoryApplied": "Prompt history applied",
"promptHistoryCleared": "Prompt history cleared",
"getPromptHistoryFailed": "Get prompt history failed",
"saveTaskHistoryFailed": "Save task history failed",
"parseTaskHistoryFailed": "Parse task history failed",
"getTaskHistoryFailed": "Get task history failed",
"getImageHistoryFailed": "Get image history failed",
"taskHistorySaved": "Task history saved",
"taskHistoryCleared": "Task history cleared",
"clickToDownload": "Click to download",
"clickApply": "Click to apply",
"justNow": "Just now",
"minutesAgo": "minutes ago",
"hoursAgo": "hours ago",
"daysAgo": "days ago",
"weeksAgo": "weeks ago",
"monthsAgo": "months ago",
"yearsAgo": "years ago",
"oneMinuteAgo": "one minute ago",
"oneHourAgo": "one hour ago",
"oneDayAgo": "one day ago",
"oneWeekAgo": "one week ago",
"oneMonthAgo": "one month ago",
"oneYearAgo": "one year ago",
"shareNotFound": "Share not found",
"backToHome": "Back to Home",
"videoNotAvailable": "Video not available",
"createdWithAI": "Created with AI",
"createSimilar": "Create Similar",
"createSimilarDescription": "Click the button to create your video with the same settings",
"shareDataImported": "Share data imported successfully",
"shareDataImportFailed": "Failed to import share data",
"templatesGeneratedByLightX2V": "The following videos are generated by LightX2V, hover/click to play",
"materials": "Materials",
"audio": "Audio",
"template": "Template",
"templateDescription": "The video is generated by LightX2V-digital human model",
"shareTemplate": "Share Template",
"useTemplate": "Use Template",
"useTemplateDescription": "You can change the input image or audio to generate your own video",
"pleaseLoginFirst": "Please login first",
"showDetails": "Show Details",
"hideDetails": "Hide Details",
"shareLinkCopied": "Share link copied to clipboard",
"materials": "Materials",
"randomTemplates": "Random Refresh Templates",
"videoNotAvailable": "Video not available",
"loadingVideo": "Loading video",
"oneClickReplication": "One-click replication",
"customizableContent": "Customizable content",
"poweredByLightX2V": "Speed-generated video - LightX2V",
"latestAIModel": "Latest AI model, rapid video generation",
"customizableCharacter": "Freely customizable characters and audio",
"userGeneratedVideo": " generated video",
"inputMaterials": "Input Materials",
"noImage": "No Images",
"noAudio": "No Audio",
"taskCompletedSuccessfully": "LightX2V has generated video for you successfully",
"taskDetails": "Task Details",
"onlyUseImage": "Only use image",
"onlyUseAudio": "Only use audio",
"reUseImage": "Reuse image",
"reUseAudio": "Reuse audio",
"templateVideo": "Speech-to-video generation template",
"description": "The video is generated by LightX2V",
"timeCost": "Time cost "
}
{
"total": "共",
"tasks": "条",
"records": "条",
"goToHome": "返回主页",
"imageTemplates": "图片素材",
"audioTemplates": "音频素材",
"noImageTemplates": "目前暂无图片素材",
"noAudioTemplates": "目前暂无音频素材",
"templateDetail": "模板详情",
"viewTemplateDetail": "查看模板详情",
"viewTaskDetails": "查看任务详情",
"templateInfo": "模板信息",
"useTemplate": "使用模板",
"model": "模型",
"type": "类型",
"inputMaterials": "上传素材",
"inputImage": "输入图片",
"inputAudio": "输入音频",
"applyImage": "只应用图片",
"applyAudio": "只应用音频",
"featuredTemplates": "精选模版",
"discoverFeaturedCreativity": "发现精选创意",
"refreshRandomTemplates": "随机获取精选模版",
"discover": "发现",
"viewMoreTemplates": "查看更多模版",
"searchTemplates": "搜索模版",
"browseCategories": "分类浏览",
"inspirationGallery": "灵感",
"viewMore": "查看更多",
"more": "更多",
"applyPrompt": "只应用提示词",
"imageApplied": "图片已应用",
"audioApplied": "音频已应用",
"promptApplied": "提示词已应用",
"copy": "复制",
"promptCopied": "提示词已复制到剪贴板",
"outputVideo": "输出视频",
"audio": "音频",
"optional": "(选填)",
"pageTitle": "LightX2V服务",
"pleaseEnterThePromptForVideoGeneration": "请输入视频生成提示词",
"describeTheContentStyleSceneOfTheVideo": "描述视频内容、风格、场景等...",
"describeTheDigitalHumanImageBackgroundStyleActionRequirements": "描述数字人表情、语气、动作等...",
"describeTheContentActionRequirementsBasedOnTheImage": "描述基于图片的视频内容、动作要求等...",
"loginSubtitle": "一个强大的视频生成平台",
"loginWithGitHub": "使用GitHub登录",
"loginWithGoogle": "使用Google登录",
"loginWithSMS": "使用短信登录",
"loggingIn": "登录中...",
"logout": "退出登录",
"loggedOut": "已退出登录",
"loginFailed": "登录失败",
"loginError": "登录过程中发生错误",
"authFailed": "认证失败,请重新登录",
"loginExpired": "登录已过期,请重新登录",
"orLoginWith": "或使用以下方式登录",
"login": "登录 / 注册",
"loginLoading": "登录中···",
"sendSmsCode": "发送验证码",
"phoneNumber": "手机号",
"verifyCode": "验证码",
"feature1": "电影级数字人视频",
"feature2": "20倍生成提速",
"feature3": "超低成本生成",
"feature4": "精准口型对齐",
"feature5": "分钟级视频时长",
"feature6": "多场景应用",
"generateVideo": "生成视频",
"history": "历史记录",
"inspiration": "灵感",
"myProjects": "我的项目",
"discoverCreativity": "发现创意,激发灵感",
"searchInspiration": "搜索灵感...",
"refresh": "刷新任务列表",
"refreshTasks": "刷新任务列表",
"noHistoryTasks": "暂无历史任务",
"startToCreateYourFirstAIVideo": "开始创建你的第一个AI视频吧",
"switchLanguage": "切换语言",
"selectTaskType": "选择任务类型",
"selectTaskTypeFirst": "请先选择任务类型",
"noHistoryRecords": "暂无历史记录",
"imageHistoryAutoSave": "开始使用图片后,历史记录将自动保存",
"audioHistoryAutoSave": "开始使用音频后,历史记录将自动保存",
"clearHistory": "清空历史记录",
"clear": "清空",
"promptHistoryAutoSave": "开始创建任务后,提示词将自动保存",
"searchTasks": "搜索任务",
"initializationFailed": "初始化失败,请刷新页面重试",
"whatDoYouWantToDo": "今天想做什么样的视频呢?",
"whatMaterialsDoYouNeed": "创作视频需要什么素材呢?",
"pleaseEnterTheMostDetailedVideoScript": "请输入尽可能详细的视频脚本",
"pleaseUploadAnImageAsTheFirstFrameOfTheVideoAndTheMostDetailedVideoScript": "请上传一张图片作为视频的首帧图,以及尽可能详细的视频脚本",
"pleaseUploadARoleImageAnAudioAndTheGeneralVideoRequirements": "请上传一张角色图片、一段音频、以及大致的视频要求",
"collapseCreationArea": "收起创作区域",
"startCreatingVideo": "开始创作视频···",
"aiIsGeneratingYourVideo": "LightX2V 正在光速生成您的视频...",
"taskProgress": "任务进度",
"subtask": "子任务",
"queuePosition": "还需等待",
"taskSubmittedSuccessfully": "任务已提交成功,加速处理中...",
"taskQueuePleaseWait": "任务有点多,加速排队中...",
"availableWorker": "可用Worker",
"videoGeneratingFailed": "视频生成失败",
"sorryYourVideoGenerationTaskFailed": "很抱歉,您的视频生成任务未能完成。",
"thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore": "此任务已被取消,您可以重新生成或查看之前上传的素材。",
"taskCompleted": "任务已完成",
"taskFailed": "任务失败",
"taskCancelled": "任务已取消",
"taskRunning": "任务运行中",
"taskPending": "任务排队中",
"taskInfo": "任务信息",
"taskID": "任务ID",
"taskType": "任务类型",
"modelName": "模型名称",
"createTime": "创建时间",
"updateTime": "更新时间",
"viewFailureReason": "查看失败原因",
"failureReason": "失败原因",
"noPrompt": "无提示词",
"uploadMaterials": "上传素材",
"loading": "加载中···",
"image": "图片",
"shareTemplate": "分享模板",
"copyShareLink": "复制分享链接",
"audioFile": "音频文件",
"status": "状态",
"browserNotSupported": "您的浏览器不支持播放",
"videoLoadFailed": "视频加载失败",
"loadingVideo": "加载视频中···",
"videoGenerating": "视频生成中",
"succeed": "成功",
"success": "成功",
"failed": "失败",
"running": "运行中",
"pending": "排队中",
"remaining": "剩余",
"cancelled": "已取消",
"all": "全部",
"reuseTask": "复用",
"regenerateTask": "重试",
"cancelTask": "取消",
"retryTask": "重试",
"downloadTask": "下载视频",
"downloadVideo": "下载视频",
"deleteTask": "删除",
"createVideo": "创建视频",
"selectTemplate": "选择模板",
"uploadImage": "上传图片",
"uploadAudio": "上传音频",
"recordAudio": "录音",
"recording": "录音中...",
"takePhoto": "拍照",
"retake": "重拍",
"usePhoto": "使用照片",
"upload": "上传",
"stopRecording": "停止录音",
"recordingStarted": "开始录音",
"recordingStopped": "录音已停止",
"recordingCompleted": "录音完成",
"recordingFailed": "录音失败",
"enterPrompt": "输入提示词",
"selectModel": "选择模型",
"startGeneration": "开始生成",
"templates": "素材",
"textToVideo": "文生视频",
"imageToVideo": "图生视频",
"speechToVideo": "数字人",
"prompt": "提示词",
"negativePrompt": "负面提示词",
"promptTemplates": "提示词模板",
"promptHistory": "提示词历史",
"uploadImageFile": "上传图片文件",
"uploadAudioFile": "上传音频文件",
"dragDropHere": "拖拽文件到此处或点击上传",
"supportedImageFormats": "支持jpg、png、webp图片格式(10MB以内)",
"supportedAudioFormats": "支持mp3、m4a、wav音频格式(120s以内)",
"maxFileSize": "最大文件大小",
"taskId": "任务ID",
"taskStatus": "任务状态",
"createdAt": "创建时间",
"completedAt": "完成时间",
"duration": "持续时间",
"confirm": "确认",
"cancel": "取消",
"save": "保存",
"edit": "编辑",
"delete": "删除",
"close": "关闭",
"back": "返回",
"next": "下一步",
"previous": "上一步",
"finish": "完成",
"submitting": "提交中...",
"t2vHint1": "输入文字描述,AI将为您生成精彩的视频内容",
"t2vHint2": "支持多种风格:写实、动画、艺术等",
"t2vHint3": "可以描述场景、动作、情感等细节",
"t2vHint4": "让您的创意通过文字变成生动的视频",
"i2vHint1": "上传一张图片,AI将为您生成动态视频",
"i2vHint2": "支持多种图片格式:JPG、PNG、WebP等",
"i2vHint3": "可以生成各种风格的动态效果",
"i2vHint4": "让静态图片动起来,创造无限可能",
"s2vHint1": "上传一张角色图片+一段音频",
"s2vHint2": "AI将让角色根据音频内容说话和动作",
"s2vHint3": "让您的角色栩栩如生地动起来",
"s2vHint4": "来创造属于您的专属数字人吧",
"operationSuccess": "操作成功",
"operationFailed": "操作失败",
"pleaseWait": "请稍候...",
"noData": "暂无数据",
"errorOccurred": "发生错误",
"networkError": "网络错误",
"serverError": "服务器错误",
"deleteTaskConfirm": "删除任务?",
"deleteTaskConfirmMessage": "删除后无法恢复,包括任务记录、生成的文件、相关数据。此操作不可撤销!",
"confirmDelete": "删除",
"regenerateTaskConfirm": "重新生成任务?",
"regenerateTaskConfirmMessage": "重新生成将删除当前任务和已生成的内容,然后使用相同参数创建新任务。此操作不可撤销!",
"confirmRegenerate": "重新生成",
"regeneratingTaskAlert": "正在重新生成任务...",
"deletingTaskAlert": "正在删除任务...",
"taskDeletedSuccessAlert": "任务删除成功",
"deleteTaskFailedAlert": "删除任务失败",
"getTaskDetailFailedAlert": "获取任务详情失败",
"taskNotExistAlert": "任务不存在",
"loadTaskFilesFailedAlert": "加载任务文件失败",
"taskMaterialReuseSuccessAlert": "任务素材复用成功",
"loadTaskDataFailedAlert": "加载任务数据失败",
"fileUnavailableAlert": "文件不可用",
"downloadFailedAlert": "下载失败",
"taskSubmitSuccessAlert": "任务提交成功",
"taskSubmitFailedAlert": "任务提交失败",
"submitTaskFailedAlert": "任务提交失败",
"fileDownloadSuccessAlert": "文件下载成功",
"getTaskResultFailedAlert": "获取结果失败",
"downloadTaskResultFailedAlert": "下载结果失败",
"viewTaskResultFailedAlert": "查看结果失败",
"cancelTaskConfirm": "取消任务?",
"cancelTaskConfirmMessage": "取消任务后任务将停止执行,已生成的部分结果可能丢失,可以稍后重新生成。",
"confirmCancel": "取消",
"taskCancelSuccessAlert": "任务取消成功",
"cancelTaskFailedAlert": "取消任务失败",
"taskRetrySuccessAlert": "任务重试成功",
"retryTaskFailedAlert": "重试任务失败",
"taskRegenerateSuccessAlert": "任务重新生成成功",
"regenerateTaskFailedAlert": "重新生成任务失败",
"taskNotFoundAlert": "任务未找到",
"seconds": "秒",
"minutes": "分钟",
"hours": "小时",
"days": "天",
"weeks": "周",
"months": "月",
"years": "年",
"position": "位置",
"calculating": "计算中",
"completed": "已完成",
"unknown": "未知",
"queueing": "排队中",
"overallProgress": "总体进度",
"queueStatus": "排队状态",
"templateApplied": "已应用模板",
"promptHistoryApplied": "已应用历史提示词",
"promptHistoryCleared": "提示词历史已清空",
"getPromptHistoryFailed": "获取提示词历史失败",
"saveTaskHistoryFailed": "保存任务历史失败",
"parseTaskHistoryFailed": "解析任务历史失败",
"getTaskHistoryFailed": "获取任务历史失败",
"getImageHistoryFailed": "获取图片历史失败",
"taskHistorySaved": "任务历史已保存",
"taskHistoryCleared": "任务历史已清空",
"clickToDownload": "点击下载",
"clickApply": "点击应用",
"justNow": "刚刚",
"minutesAgo": "分钟前",
"hoursAgo": "小时前",
"daysAgo": "天前",
"weeksAgo": "周前",
"monthsAgo": "月前",
"yearsAgo": "年前",
"oneMinuteAgo": "一分钟前",
"oneHourAgo": "一小时前",
"oneDayAgo": "一天前",
"oneWeekAgo": "一周前",
"oneMonthAgo": "一个月前",
"oneYearAgo": "一年前",
"shareNotFound": "分享不存在",
"backToHome": "返回首页",
"videoNotAvailable": "视频不可用",
"shareLinkCopied": "分享链接已复制到剪贴板",
"createdWithAI": "由AI生成",
"createSimilar": "做同款",
"createSimilarDescription": "点击按钮使用相同的设置创建您的视频",
"templatesGeneratedByLightX2V": "以下视频由LightX2V生成,鼠标悬停/点击播放",
"randomTemplates": "随机刷新模版",
"shareDataImported": "分享数据已导入",
"shareDataImportFailed": "分享数据导入失败",
"materials": "素材",
"audio": "音频",
"template": "模板",
"templateDescription": "该视频由LightX2V-数字人模型生成",
"shareTemplate": "分享模板",
"useTemplate": "一键使用模板",
"useTemplateDescription": "您可以任意改变输入的图像或是语音,生成属于您自己的视频",
"pleaseLoginFirst": "请先登录",
"showDetails": "查看详情",
"hideDetails": "隐藏详情",
"materials": "素材",
"videoNotAvailable": "视频不可用",
"loadingVideo": "正在加载视频",
"oneClickReplication": "一键复刻同款",
"customizableContent": "可自定义内容",
"poweredByLightX2V": "速生视频 - LightX2V",
"latestAIModel": "最新AI模型,飞速生成视频",
"customizableCharacter": "可自由更换角色与音频",
"userGeneratedVideo": "生成的视频",
"inputMaterials": "输入素材",
"noImage": "暂无图片",
"noAudio": "暂无音频",
"taskCompletedSuccessfully": "LightX2V 已成功为您生成视频",
"taskDetails": "任务详情",
"onlyUseImage": "仅使用图片",
"onlyUseAudio": "仅使用音频",
"reUseImage": "复用图片",
"reUseAudio": "复用音频",
"templateVideo": "语音驱动视频生成模板",
"description": "该视频由 LightX2V 生成",
"timeCost": "耗时 "
}
import { createApp } from 'vue'
import router from './router'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
import i18n, { initLanguage } from './utils/i18n'
const app = createApp(App)
const pinia = createPinia()
app.use(i18n)
app.use(pinia)
app.use(router)
// 初始化语言
initLanguage().then(() => {
app.mount('#app')
})
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Layout from '../views/Layout.vue'
import Generate from '../components/Generate.vue'
import Projects from '../components/Projects.vue'
import Inspirations from '../components/Inspirations.vue'
import Share from '../views/Share.vue'
const routes = [
{ path: '/', redirect: '/login' },
{
path: '/login', name: 'Login', component: Login, meta: { requiresAuth: false }
},
{
path: '/share/:shareId', name: 'Share', component: Share, meta: { requiresAuth: false }
},
{
path: '/home',
component: Layout,
meta: {
requiresAuth: true
},
children: [
{
path: '/generate',
name: 'Generate',
component: Generate,
meta: { requiresAuth: true },
props: route => ({ query: route.query })
},
{
path: '/projects',
name: 'Projects',
component: Projects,
meta: { requiresAuth: true },
props: route => ({ query: route.query })
},
{
path: '/inspirations',
name: 'Inspirations',
component: Inspirations,
meta: { requiresAuth: true },
props: route => ({ query: route.query })
},
{
path: '/task/:taskId',
name: 'TaskDetail',
component: Projects,
meta: { requiresAuth: true },
props: route => ({ taskId: route.params.taskId, query: route.query })
},
{
path: '/template/:templateId',
name: 'TemplateDetail',
component: Inspirations,
meta: { requiresAuth: true },
props: route => ({ templateId: route.params.templateId, query: route.query })
},
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/404.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
// router/index.js
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('accessToken')
console.log('路由守卫 - token:', token)
console.log('路由守卫 - to.path:', to.path)
// 如果是不需要登录的页面,直接放行
if (to.meta.requiresAuth === false) {
console.log('不需要登录的页面,直接放行')
// 如果已登录用户访问登录页面,重定向到生成页面
if (token && to.path === '/login') {
console.log('已登录用户访问登录页,重定向到生成页')
next('/generate');
} else {
next();
}
return;
}
// 需要登录的页面
if (!token) { // 未登录
console.log('需要登录但未登录,跳转到登录页')
next('/login');
} else {
console.log('已登录')
if (to.path === '/') { // 已登录且在首页
console.log('已登录且在首页,跳转到生成页')
next('/generate');
} else { // 已登录且不在首页
console.log('已登录,放行')
next();
}
}
})
export default router;
@import "tailwindcss";
@theme {
--color-primary: #9a72ff;
--color-secondary: #1b1240;
--color-accent: #b78bff;
--color-dark: #0b0a20;
--color-dark-light: #0f0e22;
--color-laser-purple: #d2c1ff;
--color-neon-purple: #a88bff;
--color-electric-purple: #8e88ff;
--font-display: "Inter", "sans-serif";
--gradient-primary: linear-gradient(135deg, #d2c1ff, #a88bff, #8e88ff);
--gradient-main: linear-gradient(135deg, #0b0a20 0%, #1b1240 50%, #0f0e22 100%);
--box-shadow-neon: 0 0 10px rgba(154, 114, 255, 0.5), 0 0 20px rgba(154, 114, 255, 0.3);
--box-shadow-neon-lg: 0 0 15px rgba(154, 114, 255, 0.7), 0 0 30px rgba(154, 114, 255, 0.5);
--box-shadow-laser: 0 0 20px rgba(154, 114, 255, 0.8), 0 0 40px rgba(154, 114, 255, 0.6), 0 0 60px rgba(154, 114, 255, 0.4), 0 0 80px rgba(154, 114, 255, 0.2);
--box-shadow-laser-intense: 0 0 25px rgba(154, 114, 255, 0.9), 0 0 50px rgba(154, 114, 255, 0.7), 0 0 75px rgba(154, 114, 255, 0.5), 0 0 100px rgba(154, 114, 255, 0.3), 0 0 125px rgba(154, 114, 255, 0.1);
--box-shadow-electric: 0 0 15px rgba(124, 106, 255, 0.8), 0 0 30px rgba(124, 106, 255, 0.6), 0 0 45px rgba(124, 106, 255, 0.4);
};
:root {
--gradient-primary: linear-gradient(135deg, #d2c1ff, #a88bff, #8e88ff);
color: white;
}
/* 减少登录页面闪烁 */
.login-container {
transition: opacity 0.3s ease-in-out;
}
.main-container {
transition: opacity 0.3s ease-in-out;
}
/* 防止翻译闪烁 */
.app-loading {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.app-loaded {
opacity: 1;
}
/* 确保html和body能够正确填充 */
html {
height: 100%;
}
/* 确保body和app容器能够正确填充 */
body {
margin: 0;
padding: 0;
width: 125vw;
height: 125vh;
overflow-x: hidden;
overflow-y: auto;
/* 整体缩放80% */
transform: scale(0.8);
transform-origin: top left;
}
@layer utilities {
.content-auto {
content-visibility: auto;
}
.text-gradient {
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 新增渐变图标颜色类 */
.text-gradient-primary {
background: var(--gradient-primary);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: rgba(210, 193, 255, 0.6);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background-color: rgba(31, 41, 55, 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: rgba(210, 193, 255, 0.8);
}
/* 自定义滚动条颜色 */
.scrollbar-thumb-laser-purple\/30::-webkit-scrollbar-thumb {
background-color: rgba(168, 85, 247, 0.3);
}
.scrollbar-track-gray-800\/30::-webkit-scrollbar-track {
background-color: rgba(31, 41, 55, 0.3);
}
/* 历史任务区域滚动条样式 - 与主内容区域保持一致 */
.history-tasks-scroll::-webkit-scrollbar {
width: 8px !important;
}
.history-tasks-scroll::-webkit-scrollbar-track {
background: rgba(27, 18, 64, 0.3) !important;
border-radius: 4px;
}
.history-tasks-scroll::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, rgba(210, 193, 255, 0.8), rgba(168, 139, 255, 0.8)) !important;
border-radius: 4px;
border: 1px solid rgba(210, 193, 255, 0.3);
}
.history-tasks-scroll::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, rgba(210, 193, 255, 1), rgba(168, 139, 255, 1)) !important;
}
/* 滚动条样式 */
.main-scrollbar::-webkit-scrollbar {
width: 8px;
}
.main-scrollbar::-webkit-scrollbar-track {
border-radius: 4px;
}
.main-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, rgba(210, 193, 255, 0.8), rgba(168, 139, 255, 0.8));
border-radius: 4px;
border: 1px solid rgba(210, 193, 255, 0.3);
}
.main-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, rgba(210, 193, 255, 1), rgba(168, 139, 255, 1));
}
/* 确保内容可以正常滚动 */
.main-scrollbar {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 0.5) infinite;
}
.animate-electric-pulse {
animation: electricPulse 1.5s ease-in-out infinite;
}
@keyframes electricPulse {
0%, 100% {
box-shadow: 0 0 15px rgba(142, 136, 255, 0.8), 0 0 30px rgba(142, 136, 255, 0.6);
transform: scale(1);
}
50% {
box-shadow: 0 0 25px rgba(142, 136, 255, 1), 0 0 50px rgba(142, 136, 255, 0.8), 0 0 75px rgba(142, 136, 255, 0.4);
transform: scale(1.02);
}
}
.bg-laser-gradient {
background: linear-gradient(135deg, #d2c1ff 0%, #a88bff 25%, #8e88ff 50%, #d2c1ff 75%, #a88bff 100%);
background-size: 200% 200%;
animation: gradientShift 3s ease-in-out infinite;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.text-laser-glow {
text-shadow: 0 0 10px rgba(154, 114, 255, 0.8), 0 0 20px rgba(154, 114, 255, 0.6), 0 0 30px rgba(154, 114, 255, 0.4);
}
.border-laser {
border-color: #d2c1ff;
box-shadow: 0 0 15px rgba(154, 114, 255, 0.6), inset 0 0 15px rgba(154, 114, 255, 0.1);
}
.btn-primary{
padding: 15px 25px;
border-radius: 14px;
font-weight: 500;
font-size: 14px;
letter-spacing: 0.2px;
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, #d2c1ff, #a88bff, #8e88ff);
border: 0;
text-decoration: none;
box-shadow: 0 10px 30px rgba(140, 110, 255, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-primary:hover{
transform: translateY(-1px);
box-shadow: 0 14px 40px rgba(140, 110, 255, 0.55);
}
/* 修复布局问题 */
.task-type-btn {
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
transition-property: color, background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.task-type-btn:hover {
background-color: rgba(154, 114, 255, 0.1);
}
.model-selection {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.upload-section {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (min-width: 768px) {
.upload-section {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.upload-area {
position: relative;
border: 2px dashed rgba(154, 114, 255, 0.4);
border-radius: 0.75rem;
padding: 1.5rem;
text-align: center;
justify-content: center;
align-items: center;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
height: 30vh;
background-color: rgba(27, 18, 64, 0.1);
}
/* 光球按钮样式 */
.floating-orb-btn {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #9a72ff, #a855f7, #ec4899);
border: none;
cursor: pointer;
transition: all 0.3s ease;
overflow: hidden;
box-shadow: 0 4px 20px rgba(154, 114, 255, 0.4);
}
.floating-orb-btn:hover {
transform: scale(1.1);
box-shadow: 0 0 40px rgba(154, 114, 255, 0.8), 0 0 80px rgba(154, 114, 255, 0.6);
}
.orb-glow {
position: absolute;
inset: -15px;
border-radius: 50%;
background: radial-gradient(circle, rgba(154, 114, 255, 0.3) 0%, transparent 70%);
animation: pulse 2s infinite;
}
.orb-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: white;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
/* 创作区域样式 */
.creation-area {
opacity: 0;
transform: scale(0.8) translateY(20px);
pointer-events: none;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: center center;
}
.creation-area.show {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.creation-area.hide {
opacity: 0;
transform: scale(0.8) translateY(20px);
pointer-events: none;
}
/* 提示文字淡入动画 */
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 提示文字滚动动画 */
.hint-fade-enter-active, .hint-fade-leave-active {
transition: all 0.5s ease-in-out;
}
.hint-fade-enter-from {
opacity: 0;
transform: translateY(20px);
}
.hint-fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.upload-area:hover {
border-color: rgba(154, 114, 255, 0.7);
box-shadow: 0 0 20px rgba(154, 114, 255, 0.8), 0 0 40px rgba(154, 114, 255, 0.6), 0 0 60px rgba(154, 114, 255, 0.4), 0 0 80px rgba(154, 114, 255, 0.2);
}
.upload-icon {
margin: 0 auto;
width: 4rem;
height: 4rem;
background-color: rgba(154, 114, 255, 0.2);
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.upload-icon {
background-color: rgba(154, 114, 255, 0.3);
}
/* 图片预览占据整个上传区域 */
.image-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(154, 114, 255, 0.1);
cursor: pointer;
}
.image-preview img {
height: 100%;
width: auto;
max-width: 100%;
display: block;
margin: 0 auto;
object-fit: contain;
transition: all 0.3s ease;
background-color: rgba(154, 114, 255, 0.1);
cursor: pointer;
}
/* 音频预览占据整个上传区域 */
.audio-preview {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(154, 114, 255, 0.1);
cursor: pointer;
}
.audio-preview audio {
width: 90%;
height: 60px;
max-height: 80%;
border-radius: 0.5rem;
background-color: rgba(27, 18, 64, 0.3);
display: block;
}
/* 确保音频控件在容器中正确显示 */
.audio-preview audio::-webkit-media-controls {
background-color: rgba(27, 18, 64, 0.5);
border-radius: 0.5rem;
}
/* 上传内容样式 */
.upload-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.btn-close {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background-color: #ef4444;
color: white;
border-radius: 9999px;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
cursor: pointer;
z-index: 20;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* 确保flexbox布局正确 */
#app {
display: flex;
width: 100%;
height: 100%;
}
.bg-linear-dark {
background-color: linear-gradient(135deg, #0b0a20 0%, #1b1240 50%, #0f0e22 100%);
}
aside {
flex-shrink: 0;
width: 280px; /* 默认展开宽度 */
min-width: 3rem; /* 最小宽度 */
max-width: 500px; /* 最大宽度 */
background-color: transparent;
border-right: 1px solid rgba(154, 114, 255, 0.4);
display: flex;
flex-direction: column;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
z-index: 10;
position: relative;
}
/* 拖拽调整器 */
.resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
background: transparent;
cursor: col-resize;
z-index: 50;
transition: background-color 0.2s ease;
}
.resize-handle:hover {
background: rgba(154, 114, 255, 0.5);
}
.resize-handle:active {
background: rgba(154, 114, 255, 0.8);
}
/* 确保拖拽手柄可见 */
.resize-handle::before {
content: '';
position: absolute;
top: 50%;
right: 1px;
width: 2px;
height: 20px;
background: rgba(154, 114, 255, 0.3);
transform: translateY(-50%);
border-radius: 1px;
}
/* 拖拽时的视觉反馈 */
.resizing {
user-select: none;
pointer-events: none;
}
.resizing * {
pointer-events: none;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
width: calc(100% - 280px); /* 主内容区域占据剩余宽度,适应展开的侧边栏 */
height: 100%;
}
/* 内容区域全屏显示 */
.content-area {
flex: 1;
overflow-y: auto;
/* background-color: #0b0a20; */
padding: 1rem;
width: 100%;
min-height: 0; /* 确保flex子元素可以收缩 */
}
/* 任务创建面板全屏 */
#task-creator {
max-width: none;
width: 90%;
}
#inspiration-gallery {
max-width: none;
width: 90%;
padding: 0 1rem;
}
/* 移动端全屏显示 */
@media (max-width: 768px) {
#task-creator {
width: 100%;
padding: 0 0.5rem;
}
#inspiration-gallery {
width: 100%;
padding: 0 0.5rem;
}
/* 任务详情面板移动端全屏 */
.task-detail-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 任务运行面板移动端全屏 */
.task-running-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 任务失败面板移动端全屏 */
.task-failed-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 任务取消面板移动端全屏 */
.task-cancelled-panel {
width: 100%;
padding: 0 0.5rem;
}
/* 移动端内容区域调整 */
.content-area {
padding: 0.5rem !important;
}
/* 移动端创作区域调整 */
.creation-area-container {
padding: 0.5rem;
}
}
/* 任务详情面板全屏 */
.task-detail-panel {
max-width: none;
width: 90%;
padding: 0 0rem;
}
/* 上传区域全屏布局 */
.upload-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
width: 100%;
}
/* 任务类型选择全屏 */
.task-type-selection {
width: 100%;
margin-bottom: 2rem;
}
.task-type-buttons {
display: flex;
width: 100%;
border-bottom: 1px solid rgba(154, 114, 255, 0.3);
}
.task-type-btn {
flex: 1;
padding: 1rem 1.5rem;
font-size: 1rem;
font-weight: 500;
transition-property: color, background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
text-align: center;
}
/* 模型选择全屏 */
.model-selection {
display: flex;
flex-wrap: wrap;
gap: 1rem;
width: 100%;
justify-content: flex-start;
}
/* 提示词输入全屏 */
.prompt-input-section {
width: 100%;
margin-bottom: 2rem;
}
.prompt-textarea {
width: 100%;
min-height: 150px;
resize: vertical;
}
/* 移动端响应式设计 */
@media (max-width: 640px) {
/* 通用移动端样式 */
.mobile-bottom-nav {
width: 100% !important;
height: auto !important;
padding: 0 !important;
backdrop-filter: blur(20px) !important;
border-top: 1px solid rgba(154, 114, 255, 0.2) !important;
z-index: 50 !important;
}
.mobile-nav-buttons {
display: flex !important;
flex-direction: row !important;
justify-content: space-around !important;
align-items: center !important;
gap: 0 !important;
padding: 1rem !important;
width: 100% !important;
}
.mobile-nav-btn {
width: 3rem !important;
height: 3rem !important;
flex-shrink: 0 !important;
}
/* 主布局调整为垂直布局 */
.flex.flex-row {
flex-direction: column;
}
/* 左侧功能区在移动端移动到下方 */
.p-2.flex.flex-col.justify-center.h-full {
margin-top: 0 !important;
padding: 1rem !important;
}
.p-2.flex.flex-col.justify-center.h-full nav {
display: flex !important;
flex-direction: row !important;
justify-content: space-around !important;
align-items: center !important;
gap: 1rem !important;
}
/* 确保按钮容器在移动端完全对齐 */
.mobile-nav-buttons .relative.group {
display: flex !important;
justify-content: center !important;
align-items: center !important;
flex: 1 !important;
margin: 0 !important;
}
/* 按钮在移动端调整大小 */
.p-2.flex.flex-col.justify-center.h-full nav button {
width: 3rem !important;
height: 3rem !important;
flex-shrink: 0 !important;
}
/* 历史任务区域调整 */
.flex-1.overflow-y-auto.p-10.content-area.main-scrollbar {
padding: 1rem !important;
}
/* 搜索和筛选区域在移动端垂直排列 */
.flex.flex-col.gap-4.mb-6 {
flex-direction: column !important;
gap: 1rem !important;
}
/* 筛选按钮在移动端换行 */
.flex.gap-2 {
flex-wrap: wrap !important;
gap: 0.5rem !important;
}
.flex.gap-2 button {
font-size: 0.75rem !important;
padding: 0.5rem 0.75rem !important;
}
/* 创建视频页面移动端适配 */
.max-w-4xl.mx-auto {
max-width: 100% !important;
padding: 0 1rem !important;
}
/* 上传区域在移动端调整 */
.upload-section {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
.upload-area {
padding: 1rem !important;
}
/* 任务类型选择在移动端调整 */
.grid.grid-cols-1.gap-4 {
grid-template-columns: 1fr !important;
gap: 0.75rem !important;
}
/* 模型选择在移动端调整 */
.grid.grid-cols-2.gap-3 {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.5rem !important;
}
/* 参数设置区域在移动端调整 */
.bg-dark-light.rounded-xl.p-6 {
padding: 1rem !important;
}
/* 提交按钮在移动端调整 */
.btn-primary.flex.items-center.justify-center.px-8.py-3 {
width: 100% !important;
padding: 1rem !important;
}
/* 灵感广场移动端适配 */
.grid.grid-cols-1.gap-6 {
grid-template-columns: 1fr !important;
gap: 1rem !important;
}
/* 模态框在移动端调整 */
.fixed.inset-0.z-50 {
padding: 1rem !important;
}
.bg-dark.rounded-2xl.shadow-2xl.max-w-4xl.w-full {
max-height: 90vh !important;
margin: 0 !important;
}
}
/* 超小屏幕适配 (iPhone SE等) */
@media (max-width: 375px) {
/* 底部导航按钮更紧凑 */
.p-2.flex.flex-col.justify-center.h-full nav button {
width: 2.5rem !important;
height: 2.5rem !important;
}
/* 主内容区域调整 */
.flex-1.flex.flex-col.min-h-0 {
margin-bottom: 4rem !important;
}
/* 搜索框和按钮调整 */
.flex.flex-col.gap-4.mb-6 input {
font-size: 0.875rem !important;
padding: 0.75rem !important;
}
.flex.gap-2 button {
font-size: 0.7rem !important;
padding: 0.4rem 0.6rem !important;
}
/* 任务卡片在超小屏幕调整 */
.bg-dark-light.rounded-xl.p-4 {
padding: 0.75rem !important;
}
/* 上传区域在超小屏幕调整 */
.upload-area {
padding: 0.75rem !important;
}
.upload-area p {
font-size: 0.875rem !important;
}
/* 模态框在超小屏幕调整 */
.fixed.inset-0.z-50 {
padding: 0.5rem !important;
}
.bg-dark.rounded-2xl.shadow-2xl.max-w-4xl.w-full {
max-height: 95vh !important;
}
/* 超小屏幕表单优化 */
.sms-login-form .form-control {
font-size: 13px !important;
padding: 12px 16px !important;
}
.sms-login-form .btn-sms-code {
min-width: 100px !important;
font-size: 12px !important;
padding: 6px 12px !important;
}
.sms-login-form .btn-placeholder {
min-width: 100px !important;
}
}
/* 分页组件样式 */
.pagination-container {
border-bottom: 1px solid rgba(154, 114, 255, 0.15);
padding-bottom: 0.5rem;
}
.pagination-btn-compact {
background: rgba(27, 18, 64, 0.2);
border: 1px solid rgba(154, 114, 255, 0.2);
color: rgba(255, 255, 255, 0.6);
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
.pagination-btn-compact:hover:not(.disabled) {
background: rgba(154, 114, 255, 0.15);
border-color: rgba(154, 114, 255, 0.4);
color: rgba(255, 255, 255, 0.8);
transform: translateY(-0.5px);
}
.pagination-btn-compact.active {
background: linear-gradient(135deg, rgba(154, 114, 255, 0.6), rgba(168, 139, 255, 0.6));
border-color: rgba(154, 114, 255, 0.6);
color: white;
box-shadow: 0 0 6px rgba(154, 114, 255, 0.4);
}
.pagination-btn-compact.disabled {
opacity: 0.3;
cursor: not-allowed;
background: rgba(27, 18, 64, 0.05);
border-color: rgba(154, 114, 255, 0.05);
color: rgba(255, 255, 255, 0.2);
}
.pagination-btn-compact.disabled:hover {
transform: none;
background: rgba(27, 18, 64, 0.05);
border-color: rgba(154, 114, 255, 0.05);
color: rgba(255, 255, 255, 0.2);
}
/* 页码输入框样式 */
.page-input {
width: 8px;
height: 5px;
text-align: center;
font-size: 12px;
border-radius: 4px;
background: linear-gradient(135deg, rgba(154, 114, 255, 0.3), rgba(168, 139, 255, 0.3));
border: 1px solid rgba(154, 114, 255, 0.4);
color: white;
outline: none;
transition: all 0.2s ease;
font-weight: 500;
}
.page-input:focus {
border-color: rgba(154, 114, 255, 0.8);
background: linear-gradient(135deg, rgba(154, 114, 255, 0.5), rgba(168, 139, 255, 0.5));
box-shadow: 0 0 6px rgba(154, 114, 255, 0.4);
}
.page-input::-webkit-outer-spin-button,
.page-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.page-input[type=number] {
-moz-appearance: textfield;
}
/* 修复状态指示器 */
.status-indicator {
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* 修复按钮样式 */
.btn-primary {
padding: 12px 22px;
border-radius: 14px;
font-weight: 700;
letter-spacing: 0.2px;
background: linear-gradient(135deg, #d2c1ff, #a88bff, #8e88ff);
border: 0;
text-decoration: none;
box-shadow: 0 10px 30px rgba(140, 110, 255, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
display: inline-block;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 14px 40px rgba(140, 110, 255, 0.55);
}
/* 修复模型按钮样式 */
.model-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
border: 1px solid;
}
.model-btn.active {
background-color: rgba(154, 114, 255, 0.2);
border-color: rgba(154, 114, 255, 0.4);
box-shadow: 0 0 20px rgba(154, 114, 255, 0.8), 0 0 40px rgba(154, 114, 255, 0.6), 0 0 60px rgba(154, 114, 255, 0.4), 0 0 80px rgba(154, 114, 255, 0.2);
animation: electricPulse 1.5s ease-in-out infinite;
}
/* 确保内容区域正确滚动 */
.content-scroll {
flex: 1;
overflow-y: auto;
}
/* 任务进行中面板样式 */
.task-running-panel .animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 0.5) infinite;
}
/* 进度条样式 */
.progress-container {
background: rgba(27, 18, 64, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.progress-bar {
width: 100%;
height: 8px;
background: rgba(154, 114, 255, 0.2);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #9a72ff, #a88bff);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.subtask-item {
background: rgba(27, 18, 64, 0.2);
border: 1px solid rgba(154, 114, 255, 0.2);
border-radius: 8px;
padding: 0.75rem;
margin: 0.5rem 0;
}
.subtask-header {
display: flex;
justify-content: between;
align-items: center;
margin-bottom: 0.5rem;
}
.subtask-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.subtask-status.pending {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.subtask-status.running {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.subtask-info {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
margin-top: 0.25rem;
}
/* 任务失败面板样式 */
.task-failed-panel .bg-red-500\/10 {
background-color: rgba(239, 68, 68, 0.1);
}
.error-details {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
text-align: left;
}
.error-details pre {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 0.75rem;
margin: 0.5rem 0 0 0;
font-size: 0.75rem;
color: #fca5a5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.subtask-error {
background: rgba(239, 68, 68, 0.05);
border-left: 3px solid #ef4444;
padding: 0.75rem;
margin: 0.5rem 0;
border-radius: 0 4px 4px 0;
}
.subtask-error pre {
background: #1a1a1a;
border: 1px solid #dc2626;
border-radius: 6px;
padding: 12px;
margin: 8px 0;
color: #fca5a5;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.error-details {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-detail-panel video {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 素材预览样式 */
/* 任务状态指示器增强 */
.status-indicator {
position: relative;
}
.status-indicator::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 0.25rem;
height: 0.25rem;
background-color: currentColor;
border-radius: 50%;
opacity: 0.8;
}
/* 响应式任务面板 */
@media (max-width: 768px) {
.task-detail-panel {
padding: 0 0.5rem;
}
}
/* 提示消息动画 */
.animate-slide-down {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
0% {
opacity: 0;
transform: translate(-50%, -100%);
}
100% {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* 提示消息样式 - 统一浅色透明背景 */
.alert {
backdrop-filter: blur(15px);
background: rgba(0, 0, 0, 0.8);
border-radius: 0.75rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
color: #fff;
}
}
.floating-toggle-btn {
position: fixed;
top: 50%;
left: 256px; /* 默认位置,对应 w-64 (256px) */
transform: translateY(-50%);
width: 20px;
height: 40px;
background: linear-gradient(135deg, #1a1a2e 0%, #2a2a4e 50%, #1e1e3e 100%);
border: 1px solid rgba(139, 92, 246, 0.3);
border-left: none;
border-radius: 0 8px 8px 0;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
z-index: 20;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
}
.floating-toggle-btn:hover {
background: linear-gradient(135deg, #2a2a4e 0%, #3a3a5e 50%, #2e2e4e 100%);
color: #8b5cf6;
box-shadow: 2px 0 12px rgba(139, 92, 246, 0.3);
}
.floating-toggle-btn.collapsed {
border-radius: 0 8px 8px 0;
border-left: 1px solid rgba(139, 92, 246, 0.3);
border-right: none;
}
.resizing .floating-toggle-btn {
transition: none !important;
}
.left-glow-zone.show-glow {
opacity: 1;
}
.history-section {
max-height: calc(100% - 200px);
border-radius: 0 12px 12px 0;
border: 2px solid rgba(139, 92, 246, 0.4);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
margin: 8px 8px 8px 0;
transition: all 0.3s ease;
cursor: pointer;
background: rgba(139, 92, 246, 0.05);
}
.history-section:hover {
background: rgba(139, 92, 246, 0.05) !important;
border-color: rgba(139, 92, 246, 0.15) !important;
box-shadow: 0 0 20px rgba(154, 114, 255, 0.8), 0 0 40px rgba(154, 114, 255, 0.6), 0 0 60px rgba(154, 114, 255, 0.4), 0 0 80px rgba(154, 114, 255, 0.2);
transform: translateY(-2px);
}
.history-section:hover {
background: rgba(139, 92, 246, 0.08) !important;
}
/* 修复任务项样式 */
.task-item {
border: 1px solid rgba(139, 92, 246, 0.3);
padding: 0.75rem;
border-radius: 0.5rem;
cursor: pointer;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
transition: all 0.2s ease;
}
.task-item:hover {
border: 1px solid rgba(167, 132, 255, 0.2);
background-color: rgba(167, 132, 255, 0.2);
transform: translateX(5px);
}
/* 任务操作菜单样式 */
.task-menu-container {
position: relative;
}
.task-menu-dropdown {
animation: fadeInUp 0.2s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-menu-item {
transition: all 0.2s ease;
}
.task-menu-item:hover {
transform: translateX(2px);
}
/* 语言切换器样式 */
.language-switcher {
position: relative;
}
.language-switcher button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.language-switcher button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(154, 114, 255, 0.3);
}
.language-switcher .rotate-180 {
transform: rotate(180deg);
}
/* 语言选择下拉菜单动画 */
.language-switcher .absolute {
animation: slideDown 0.2s ease-out;
}
/* 短信登录样式 */
.sms-login-form {
background: transparent;
border-radius: 0;
padding: 0;
border: none;
margin: 0 auto 2rem auto;
max-width: 80%;
animation: slideDown 0.3s ease-out;
}
/* 输入组样式 */
.input-group {
display: flex;
gap: 12px;
align-items: stretch;
margin-bottom: 1rem;
}
.input-group .form-control {
flex: 1;
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: white;
padding: 14px 18px;
font-size: 14px;
font-weight: 400;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
position: relative;
min-width: 0;
height: 48px;
}
.input-group .form-control:focus {
outline: none;
border-color: #9a72ff;
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 4px rgba(154, 114, 255, 0.15),
0 8px 25px rgba(154, 114, 255, 0.1);
transform: translateY(-1px);
}
.input-group .form-control:hover:not(:focus) {
border-color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
}
.input-group .form-control::placeholder {
color: rgba(255, 255, 255, 0.5);
font-weight: 400;
transition: color 0.3s ease;
}
.input-group .form-control:focus::placeholder {
color: rgba(255, 255, 255, 0.3);
}
/* 单独的输入框样式 */
.form-control {
width: 50%;
border: 2px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: white;
padding: 16px 20px;
font-size: 12px;
font-weight: 400;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(10px);
position: relative;
}
.form-control:focus {
outline: none;
border-color: #9a72ff;
background: rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 4px rgba(154, 114, 255, 0.15),
0 8px 25px rgba(154, 114, 255, 0.1);
transform: translateY(-1px);
}
.input-group .btn {
border-radius: 16px;
padding: 16px 16px;
font-weight: 500;
font-size: 14px;
white-space: nowrap;
min-width: 100px;
flex-shrink: 0;
border: 2px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.input-group .btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 25px rgba(154, 114, 255, 0.2);
}
.input-group .btn:active {
transform: translateY(0);
}
/* 分隔线样式 */
.divider {
position: relative;
text-align: center;
margin: 20px 0;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0.1) 80%,
transparent 100%);
}
.divider-text {
background: rgba(27, 18, 64, 0.9);
padding: 8px 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 500;
position: relative;
z-index: 1;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
/* 社交登录按钮样式 */
.social-login-buttons {
display: flex;
justify-content: center;
gap: 20px;
}
.btn-icon {
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.btn-icon:hover {
transform: translateY(-3px) scale(1.05);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.btn-icon:active {
transform: translateY(-1px) scale(1.02);
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 10px 30px rgba(255, 255, 255, 0.1);
}
.btn-icon:hover i {
color: #ffffff;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
}
.btn-icon {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.3);
}
/* 图标按钮的波纹效果 */
.btn-icon::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
transform: translate(-50%, -50%);
transition: width 0.3s ease, height 0.3s ease;
}
.btn-icon:hover::before {
width: 100%;
height: 100%;
}
/* 提交按钮区域样式 */
.login-submit-section {
display: flex;
justify-content: center;
margin-top: 24px;
}
.btn-submit {
width: 100%;
padding: 10px 28px;
border-radius: 10px;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #d2c1ff, #a88bff, #8e88ff);
border: 2px solid transparent;
color: #0c0920;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
box-shadow: 0 8px 25px rgba(154, 114, 255, 0.3);
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(154, 114, 255, 0.4);
}
.btn-submit:active:not(:disabled) {
transform: translateY(0);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: 0 4px 15px rgba(154, 114, 255, 0.2);
}
.btn-submit::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s ease;
}
.btn-submit:hover::before {
left: 100%;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card .card-body {
padding: 1.5rem !important;
}
.social-login-buttons {
gap: 16px;
}
.btn-icon {
width: 42px;
height: 42px;
font-size: 18px;
}
.divider-text {
font-size: 12px;
padding: 0 12px;
}
}
@media (max-width: 480px) {
.login-card .card-body {
padding: 1rem !important;
}
.input-group .form-control {
padding: 14px 16px;
font-size: 14px;
}
.btn-icon {
width: 38px;
height: 38px;
font-size: 16px;
}
.social-login-buttons {
gap: 12px;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 语言切换时的文本过渡动画 */
.language-transition {
transition: all 0.3s ease-in-out;
}
/* 语言选项悬停效果 */
.language-switcher .absolute > div:hover {
background: bg-laser-purple/20;
transform: translateX(2px);
}
/* 当前选中语言的样式 */
.language-switcher .absolute > div[class*="bg-laser-purple"] {
background: bg-laser-purple/20;
border-left: 3px solid #9a72ff;
}
.sms-login-form .form-control {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
border-radius: 8px;
transition: all 0.3s ease;
height: 50px;
padding: 12px 16px;
font-size: 16px;
}
.sms-login-form .form-control:focus {
background: rgba(255, 255, 255, 0.15);
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
color: white;
}
.sms-login-form .form-control::placeholder {
color: rgba(255, 255, 255, 1.0);
}
.sms-login-form .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sms-login-form .btn-primary {
color: white;
transition: all 0.3s ease;
height: 50px;
padding: 12px 20px;
font-size: 16px;
font-weight: 500;
}
/* 发送验证码按钮专用样式 */
.btn-sms-code {
background: linear-gradient(135deg, #9a72ff 0%, #7c6aff 100%);
border: 2px solid rgba(255, 255, 255, 0.2);
color: white;
font-weight: 500;
font-size: 13px;
padding: 8px 16px;
border-radius: 12px;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(154, 114, 255, 0.3);
white-space: nowrap;
min-width: 110px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-sms-code:hover:not(:disabled) {
background: linear-gradient(135deg, #8a5fff 0%, #6b4aff 100%);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(154, 114, 255, 0.4);
border-color: rgba(255, 255, 255, 0.3);
}
.btn-sms-code:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(154, 114, 255, 0.3);
}
.btn-sms-code:disabled {
background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%);
color: #d1d5db;
cursor: not-allowed;
transform: none;
box-shadow: 0 1px 4px rgba(156, 163, 175, 0.2);
border-color: rgba(255, 255, 255, 0.1);
}
/* 按钮占位符样式,用于对齐 */
.btn-placeholder {
min-width: 110px;
height: 48px;
flex-shrink: 0;
}
/* 表单整体优化 */
.sms-login-form .input-group:last-child {
margin-bottom: 0;
}
/* 输入框聚焦时的统一效果 */
.sms-login-form .form-control:focus {
border-color: #9a72ff;
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 0 3px rgba(154, 114, 255, 0.2),
0 4px 12px rgba(154, 114, 255, 0.1);
transform: translateY(-1px);
}
/* 占位符文字优化 */
.sms-login-form .form-control::placeholder {
color: rgba(255, 255, 255, 0.6);
font-weight: 400;
transition: color 0.3s ease;
}
.sms-login-form .form-control:focus::placeholder {
color: rgba(255, 255, 255, 0.4);
}
/* 文本截断样式 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 侧边栏动画样式 */
.sidebar-expand {
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-text {
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateX(-10px);
}
.sidebar-text.show {
opacity: 1;
transform: translateX(0);
}
/* 平滑过渡动画 */
.smooth-transition {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 登录页面样式 */
.share-container {
min-height: 100%;
min-width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
position: relative;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
}
/* 登录页面样式 */
.login-container {
min-height: 100%;
min-width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.login-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(154, 114, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(183, 139, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(124, 106, 255, 0.05) 0%, transparent 50%);
animation: backgroundShift 20s ease-in-out infinite;
}
/* 登录页面样式 */
.main-container {
min-height: 100%;
min-width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
overflow-x: visible;
overflow-y: hidden;
display: flex;
flex-direction: column;
}
/* 顶部栏样式 */
.top-bar {
top: 0;
left: 0;
right: 0;
height: 60px;
background: rgba(11, 10, 32, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(154, 114, 255, 0.2);
z-index: 1000;
display: flex;
align-items: center;
}
.top-bar-content {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
}
.top-bar-left {
display: flex;
align-items: center;
}
.top-bar-logo {
height: 50px;
width: auto;
filter: brightness(0) invert(1);
}
.top-bar-right {
display: flex;
align-items: center;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-avatar i {
color: #9a72ff;
font-size: 16px;
}
.user-details {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.username {
font-size: 14px;
font-weight: 500;
color: #ffffff;
line-height: 1.2;
}
.user-email {
font-size: 12px;
color: #9ca3af;
line-height: 1.2;
}
.logout-btn {
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(154, 114, 255, 0.1);
border: 1px solid rgba(154, 114, 255, 0.2);
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
cursor: pointer;
}
.logout-btn:hover {
background: rgba(154, 114, 255, 0.2);
color: #9a72ff;
border-color: rgba(154, 114, 255, 0.4);
}
@keyframes backgroundShift {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.login-card {
position: relative;
overflow: hidden;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
max-width: 400px;
width: 100%;
margin: 0 auto;
}
.login-logo {
background: white;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 3rem;
font-weight: 600;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
text-align: center;
font-family: "Inter", sans-serif;
line-height: 1.2;
}
@keyframes logoGlow {
0% {
filter: drop-shadow(0 0 10px rgba(154, 114, 255, 0.5));
}
100% {
filter: drop-shadow(0 0 20px rgba(154, 114, 255, 0.8));
}
}
.login-subtitle {
color: rgba(255, 255, 255, 0.6);
font-size: 1rem;
margin-bottom: 2.5rem;
font-weight: 400;
text-align: center;
line-height: 1.5;
}
.floating-particles {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
pointer-events: none;
}
.particle {
position: absolute;
width: 4px;
height: 4px;
background: rgba(193, 169, 255, 0.6);
border-radius: 50%;
animation: floatParticle 15s linear infinite;
}
.particle:nth-child(1) { left: 10%; animation-delay: 0s; }
.particle:nth-child(2) { left: 20%; animation-delay: 12s; }
.particle:nth-child(3) { left: 30%; animation-delay: 10s; }
.particle:nth-child(4) { left: 40%; animation-delay: 6s; }
.particle:nth-child(5) { left: 50%; animation-delay: 8s; }
.particle:nth-child(6) { left: 60%; animation-delay: 14s; }
.particle:nth-child(7) { left: 70%; animation-delay: 16s; }
.particle:nth-child(8) { left: 80%; animation-delay: 2s; }
.particle:nth-child(9) { left: 90%; animation-delay: 4s; }
@keyframes floatParticle {
0% {
transform: translateY(100vh) scale(0);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100px) scale(1);
opacity: 0;
}
}
.login-features {
padding-top: 2rem;
padding-left: 10rem;
border-top: 1px solid rgba(154, 114, 255, 0.2);
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 1rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
}
.feature-icon {
width: 20px;
height: 20px;
background: linear-gradient(135deg, #9a72ff, #b78bff);
border-radius: 50%;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: white;
}
/* 登录页面进入动画 */
.login-container {
animation: fadeInUp 0.8s ease-out;
}
.login-card {
animation: slideInUp 0.6s ease-out 0.2s both;
}
.login-logo {
animation: logoGlow 3s ease-in-out infinite alternate, fadeInScale 0.8s ease-out 0.4s both;
}
.login-subtitle {
animation: fadeIn 0.8s ease-out 0.6s both;
}
.login-features {
animation: fadeIn 0.8s ease-out 1s both;
}
.feature-item {
animation: slideInLeft 0.6s ease-out both;
}
.feature-item:nth-child(1) { animation-delay: 1.2s; }
.feature-item:nth-child(2) { animation-delay: 1.4s; }
.feature-item:nth-child(3) { animation-delay: 1.6s; }
.feature-item:nth-child(4) { animation-delay: 1.8s; }
.feature-item:nth-child(5) { animation-delay: 2.0s; }
.feature-item:nth-child(6) { animation-delay: 2.2s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-logo {
font-size: 3rem;
}
.login-subtitle {
font-size: 1rem;
}
.login-card {
margin: 20px;
border-radius: 20px;
}
}
@media (max-width: 480px) {
.login-logo {
font-size: 2rem;
}
.login-subtitle {
font-size: 0.9rem;
}
.login-card .card-body {
padding: 2rem !important;
}
}
/* 简约登录页面样式 */
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-form {
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-input {
width: 100%;
padding: 1rem 1.25rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #ffffff;
font-size: 1rem;
font-weight: 400;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.4);
font-weight: 400;
}
.form-input:focus {
outline: none;
border-color: rgba(154, 114, 255, 0.4);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 3px rgba(154, 114, 255, 0.1);
}
.verify-code-container {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.verify-code-container .form-input {
flex: 1;
}
.send-code-btn {
padding: 1rem 1.25rem;
background: rgba(154, 114, 255, 0.1);
border: 1px solid rgba(154, 114, 255, 0.2);
border-radius: 12px;
color: #9a72ff;
font-size: 0.9rem;
font-weight: 500;
white-space: nowrap;
transition: all 0.3s ease;
cursor: pointer;
}
.send-code-btn:hover:not(:disabled) {
background: rgba(154, 114, 255, 0.15);
border-color: rgba(154, 114, 255, 0.3);
transform: translateY(-1px);
}
.send-code-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.login-btn {
width: 100%;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, #9a72ff, #7c6aff);
border: none;
border-radius: 12px;
color: #ffffff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.login-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(154, 114, 255, 0.3);
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.divider {
position: relative;
text-align: center;
margin: 2rem 0;
color: rgba(255, 255, 255, 0.4);
font-size: 0.9rem;
font-weight: 500;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
}
.divider span {
background: rgba(15, 14, 34, 0.95);
padding: 0 1rem;
position: relative;
z-index: 1;
}
.social-login {
display: flex;
justify-content: center;
gap: 1rem;
}
.social-btn {
width: 3rem;
height: 3rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: rgba(255, 255, 255, 0.7);
font-size: 1.25rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.social-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: #ffffff;
transform: translateY(-2px);
}
.social-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.card-body {
padding: 2.5rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.login-card {
margin: 1rem;
}
.card-body {
padding: 2rem;
}
.login-logo {
font-size: 2.5rem;
}
.login-subtitle {
font-size: 0.9rem;
margin-bottom: 2rem;
}
.form-input {
padding: 0.875rem 1rem;
font-size: 0.95rem;
}
.send-code-btn {
padding: 0.875rem 1rem;
font-size: 0.85rem;
}
.login-btn {
padding: 0.875rem 1rem;
font-size: 0.95rem;
}
.social-btn {
width: 2.75rem;
height: 2.75rem;
font-size: 1.1rem;
}
}
@media (max-width: 480px) {
.login-card {
margin: 0.5rem;
border-radius: 16px;
}
.card-body {
padding: 1.5rem;
}
.login-logo {
font-size: 2rem;
}
.login-subtitle {
font-size: 0.85rem;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-input {
padding: 0.75rem 0.875rem;
font-size: 0.9rem;
border-radius: 10px;
}
.verify-code-container {
gap: 0.5rem;
}
.send-code-btn {
padding: 0.75rem 0.875rem;
font-size: 0.8rem;
border-radius: 10px;
}
.login-btn {
padding: 0.75rem 0.875rem;
font-size: 0.9rem;
border-radius: 10px;
}
.divider {
margin: 1.5rem 0;
font-size: 0.85rem;
}
.social-login {
gap: 0.75rem;
}
.social-btn {
width: 2.5rem;
height: 2.5rem;
font-size: 1rem;
border-radius: 10px;
}
}
/* Alert动画 */
@keyframes slide-down {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.animate-slide-down {
animation: slide-down 0.3s ease-out;
}
import { createI18n } from 'vue-i18n'
import { ref } from 'vue'
const loadedLanguages = new Set()
// 创建 i18n 实例(初始只设置 locale,不加载全部语言)
const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: 'zh',
fallbackLocale: 'en',
messages: {}
})
// 异步加载语言文件
async function loadLanguageAsync(lang) {
if (!loadedLanguages.has(lang)) {
const messages = await import(`../locales/${lang}.json`)
i18n.global.setLocaleMessage(lang, messages.default)
loadedLanguages.add(lang)
}
if (i18n.global.locale.value === lang) return lang
i18n.global.locale.value = lang
localStorage.setItem('app-lang', lang) // ✅ 记住用户选择
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
return lang
}
// 初始化默认语言
async function initLanguage() {
const savedLang = localStorage.getItem('app-lang') || 'zh'
return loadLanguageAsync(savedLang)
}
async function switchLang() {
const newLang = i18n.global.locale.value === 'zh' ? 'en' : 'zh'
await loadLanguageAsync(newLang)
}
// // 语言切换功能
// const switchLanguage = (langCode) => {
// currentLanguage.value = langCode;
// localStorage.setItem('preferredLanguage', langCode);
// // 更新页面标题
// document.title = t('pageTitle');
// // 更新HTML lang属性
// document.documentElement.lang = langCode === 'zh' ? 'zh-CN' : 'en';
// };
// // 简单语言切换功能(中英文切换)
// const toggleLanguage = () => {
// const newLang = currentLanguage.value === 'zh' ? 'en' : 'zh';
// switchLanguage(newLang);
// };
const languageOptions = ref([
{ code: 'zh', name: '中文', flag: 'ZH' },
{ code: 'en', name: 'English', flag: 'EN' }
]);
export { i18n as default, loadLanguageAsync, initLanguage, switchLang, languageOptions }
This source diff could not be displayed because it is too large. You can view the blob instead.
<script setup>
import { switchToCreateView, switchToProjectsView, switchToInspirationView } from '../utils/other'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<div class="grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8">
<div class="text-center">
<p class="text-base font-semibold text-indigo-600">404</p>
<h1 class="mt-4 text-5xl font-semibold tracking-tight text-balance text-gray-900 sm:text-7xl">Page not found</h1>
<p class="mt-6 text-lg font-medium text-pretty text-gray-500 sm:text-xl/8">Sorry, we couldn’t find the page you’re looking for.</p>
<div class="mt-10 flex items-center justify-center gap-x-6">
<RouterLink to="/generate" @click="switchToCreateView" class="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Go back home</RouterLink>
<a href="#" class="text-sm font-sem/mtc/gongruihao/qinxinyi/my-project/src/components/LoginCard.vueibold text-gray-900">Contact support <span aria-hidden="true">&rarr;</span></a>
</div>
</div>
</div>
</template>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment