Commit a1ebc651 authored by xuwx1's avatar xuwx1
Browse files

updata lightx2v

parent 5a4db490
Pipeline #3149 canceled with stages
<script setup>
import { useI18n } from 'vue-i18n'
const currentYear = new Date().getFullYear()
const { t } = useI18n()
</script>
<template>
<div class="bg-transparent text-neutral-600 dark:text-neutral-300">
<div class="mx-auto w-full max-w-4xl pt-16 pb-16 text-center">
<div class="flex flex-col gap-10 items-center justify-center">
<div class="max-w-sm space-y-6">
<div class="inline-flex items-center justify-center gap-3 text-xl font-semibold tracking-tight text-neutral-900 dark:text-white">
<img src="../../public/logo.svg" alt="LightX2V" class="h-8 w-8" loading="lazy" />
<span>LightX2V</span>
</div>
<p class="leading-relaxed text-neutral-500 dark:text-neutral-400">
{{ t('footer.tagline') }}
</p>
<div class="flex flex-wrap items-center justify-center gap-5 text-sm text-neutral-500 dark:text-neutral-400">
<a href="https://www.light-ai.top/" target="_blank" rel="noopener" class="transition hover:text-neutral-900 dark:hover:text-white">{{ t('footer.links.home') }}</a>
<a href="https://github.com/ModelTC/LightX2V" target="_blank" rel="noopener"
class="inline-flex items-center gap-2 transition hover:text-neutral-900 dark:hover:text-white">
<img src="https://github.githubassets.com/favicons/favicon.svg" :alt="t('footer.alt.github')" class="h-4 w-4 transition dark:invert" loading="lazy" />
{{ t('footer.links.github') }}
</a>
<a href="https://xhslink.com/m/45NsEK8minq" class="inline-flex items-center gap-2 transition hover:text-neutral-900 dark:hover:text-white">
<img src="https://www.xiaohongshu.com/favicon.ico" :alt="t('footer.alt.xiaohongshu')" class="h-4 w-4" loading="lazy" />
{{ t('footer.links.xiaohongshu') }}
</a>
</div>
</div>
</div>
<div class="mt-12 border-t border-black/10 dark:border-white/10 pt-8 text-xs text-neutral-400 dark:text-neutral-500 flex items-center justify-center">
<p>{{ t('footer.copyright', { year: currentYear }) }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
getTaskFileUrlSync,
getTaskFileUrl,
getTaskTypeName,
formatTime,
getTaskStatusDisplay,
getStatusTextClass,
getProgressTitle,
getProgressInfo,
getOverallProgress,
getSubtaskStatusText,
getSubtaskProgress,
formatEstimatedTime,
showAlert,
cancelTask,
resumeTask,
downloadLoading,
handleDownloadFile,
getTaskFileFromCache,
apiRequest,
copyShareLink,
deleteTask,
currentTask,
startPollingTask,
openTaskDetailModal,
playVideo,
pauseVideo,
} from '../utils/other'
const { t } = useI18n()
// Props
const props = defineProps({
tasks: {
type: Array,
required: true,
default: () => []
}
})
// 响应式数据å
const isVideoLoaded = ref(false)
const isVideoError = ref(false)
const videoElement = ref(null)
const isMuted = ref(true)
// 计算属性
const sortedTasks = computed(() => {
// 按创建时间排序,最新的在前
return [...props.tasks].sort((a, b) => {
const timeA = new Date(a.created_at || a.task_id).getTime()
const timeB = new Date(b.created_at || b.task_id).getTime()
return timeB - timeA
})
})
const taskStatus = computed(() => currentTask.value?.status || 'CREATED')
const isCompleted = computed(() => taskStatus.value === 'SUCCEED')
const isRunning = computed(() => ['CREATED', 'PENDING', 'RUNNING'].includes(taskStatus.value))
const isFailed = computed(() => taskStatus.value === 'FAILED')
const isCancelled = computed(() => taskStatus.value === 'CANCEL')
// 当前任务索引(用于显示)
const currentTaskIndex = computed(() => {
return sortedTasks.value.findIndex(task => task.task_id === currentTask.value?.task_id)
})
// 获取视频URL
const videoUrl = computed(() => {
if (!isCompleted.value || !currentTask.value) return null
return getTaskFileUrlSync(currentTask.value.task_id, 'output_video')
})
// 获取图片URL(用于缩略图)
const imageUrl = computed(() => {
if (!currentTask.value) return null
return getTaskFileUrlSync(currentTask.value.task_id, 'input_image')
})
// 更新当前任务数据并启动轮询
const updateCurrentTaskData = async (task) => {
if (!task?.task_id) return
try {
const response = await apiRequest(`/api/v1/task/query?task_id=${task.task_id}`)
if (response && response.ok) {
const updatedTask = await response.json()
// 更新全局currentTask
currentTask.value = updatedTask
console.log('TaskCarousel: 更新任务数据', updatedTask)
// 如果任务还在进行中,开始轮询状态
if (['CREATED', 'PENDING', 'RUNNING'].includes(updatedTask.status)) {
startPollingTask(updatedTask.task_id)
}
}
} catch (error) {
console.warn(`TaskCarousel: 获取任务数据失败 task_id=${task.task_id}`, error.message)
}
}
// 任务切换方法
const goToPreviousTask = () => {
if (sortedTasks.value.length <= 1) return
const currentIndex = sortedTasks.value.findIndex(task => task.task_id === currentTask.value?.task_id)
if (currentIndex === -1) return
const newIndex = currentIndex > 0 ? currentIndex - 1 : sortedTasks.value.length - 1
const newTask = sortedTasks.value[newIndex]
currentTask.value = newTask
resetVideoState()
// 更新新任务的数据并启动轮询
updateCurrentTaskData(newTask)
}
const goToNextTask = () => {
if (sortedTasks.value.length <= 1) return
const currentIndex = sortedTasks.value.findIndex(task => task.task_id === currentTask.value?.task_id)
if (currentIndex === -1) return
const newIndex = currentIndex < sortedTasks.value.length - 1 ? currentIndex + 1 : 0
const newTask = sortedTasks.value[newIndex]
currentTask.value = newTask
resetVideoState()
// 更新新任务的数据并启动轮询
updateCurrentTaskData(newTask)
}
// 处理任务指示器点击
const handleTaskIndicatorClick = (task) => {
currentTask.value = task
resetVideoState()
// 更新任务数据并启动轮询
updateCurrentTaskData(task)
}
// 重置视频状态
const resetVideoState = () => {
isVideoLoaded.value = false
isVideoError.value = false
}
// 视频加载事件
const onVideoLoaded = () => {
isVideoLoaded.value = true
isVideoError.value = false
if (videoElement.value && isMuted.value) {
videoElement.value.muted = true
}
}
const onVideoError = () => {
isVideoError.value = true
isVideoLoaded.value = false
}
const toggleMute = (event) => {
event.stopPropagation()
isMuted.value = !isMuted.value
if (videoElement.value) {
videoElement.value.muted = isMuted.value
if (!isMuted.value) {
videoElement.value.play().catch(() => {})
}
}
}
const openDetail = (event) => {
event?.stopPropagation()
if (currentTask.value) {
openTaskDetailModal(currentTask.value)
}
}
// 处理取消任务
const handleCancel = async () => {
if (!currentTask.value?.task_id) return
try {
await cancelTask(currentTask.value.task_id)
} catch (error) {
console.error('取消任务失败:', error)
showAlert(t('cancelTaskFailedRetry'), 'danger')
}
}
// 处理分享任务
const handleShareTask = async () => {
if (!currentTask.value?.task_id) return
try {
await copyShareLink(currentTask.value.task_id, 'task')
// copyShareLink 函数内部已经显示了带"查看"按钮的 alert,不需要再次调用
} catch (error) {
console.error('分享失败:', error)
showAlert(t('shareFailedRetry'), 'danger')
}
}
// 处理重试任务
const handleRetry = async () => {
if (!currentTask.value?.task_id) return
try {
await resumeTask(currentTask.value.task_id)
} catch (error) {
console.error('重试任务失败:', error)
showAlert(t('retryTaskFailedRetry'), '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 handleKeydown = (event) => {
if (event.key === 'ArrowLeft') {
goToPreviousTask()
} else if (event.key === 'ArrowRight') {
goToNextTask()
}
}
// 生命周期
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
// 初始化时设置第一个任务为当前任务
if (sortedTasks.value.length > 0 && !currentTask.value) {
const firstTask = sortedTasks.value[0]
currentTask.value = firstTask
// 更新任务数据并启动轮询
updateCurrentTaskData(firstTask)
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<!-- Apple 风格任务轮播 -->
<div class="w-full max-w-[500px] mx-auto">
<!-- 任务计数器 - Apple 风格 -->
<div class="flex justify-center items-center text-sm font-medium text-[#86868b] dark:text-[#98989d] mb-4 tracking-tight">
{{ currentTaskIndex + 1 }} / {{ sortedTasks.length }}
</div>
<!-- 视频区域 -->
<div class="flex flex-col items-center gap-6 relative">
<!-- 左侧导航箭头 - Apple 极简风格 -->
<button
v-if="sortedTasks.length > 1"
@click="goToPreviousTask"
class="absolute top-1/2 -translate-y-1/2 left-[-10px] sm:left-[-20px] md:left-[-40px] lg:left-[-60px] w-[44px] h-[44px] rounded-full border-0 cursor-pointer flex items-center justify-center text-base transition-all duration-200 ease-out z-10 bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed"
:disabled="sortedTasks.length <= 1">
<i class="fas fa-chevron-left"></i>
</button>
<!-- 右侧导航箭头 - Apple 极简风格 -->
<button
v-if="sortedTasks.length > 1"
@click="goToNextTask"
class="absolute top-1/2 -translate-y-1/2 right-[-10px] sm:right-[-20px] md:right-[-40px] lg:right-[-60px] w-[44px] h-[44px] rounded-full border-0 cursor-pointer flex items-center justify-center text-base transition-all duration-200 ease-out z-10 bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed"
:disabled="sortedTasks.length <= 1">
<i class="fas fa-chevron-right"></i>
</button>
<!-- 视频容器 - Apple 圆角和阴影 -->
<div class="w-full max-w-[280px] sm:max-w-[300px] md:max-w-[400px] lg:max-w-[400px] aspect-[9/16] bg-black dark:bg-[#000000] rounded-[16px] overflow-hidden shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.5)] relative cursor-pointer transition-all duration-200 hover:shadow-[0_12px_32px_rgba(0,0,0,0.2)] dark:hover:shadow-[0_12px_32px_rgba(0,0,0,0.6)]"
@click="openDetail"
:title="t('viewTaskDetails')">
<button
class="absolute top-3 left-3 z-20 w-10 h-10 flex items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm transition hover:bg-black/55 active:scale-95"
@click.stop="openDetail"
:title="t('viewTaskDetails')"
:aria-label="t('viewTaskDetails')">
<i class="fas fa-info"></i>
</button>
<!-- 已完成:显示视频播放器 -->
<video
v-if="isCompleted && videoUrl"
:src="videoUrl"
:poster="imageUrl"
class="w-full h-full object-contain"
controls
preload="auto"
autoplay
muted
playsinline
webkit-playsinline
@mouseenter="playVideo($event)"
@mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"
ref="videoElement">
{{ t('browserNotSupported') }}
</video>
<button
v-if="isCompleted && videoUrl"
class="absolute top-3 right-3 z-20 w-10 h-10 flex items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm transition hover:bg-black/55 active:scale-95"
@click.stop="toggleMute"
:title="isMuted ? t('unmute') : t('mute')">
<i :class="isMuted ? 'fas fa-volume-mute' : 'fas fa-volume-up'"></i>
</button>
<!-- 进行中:Apple 风格加载状态 -->
<div v-else-if="isRunning" class="w-full h-full flex flex-col items-center justify-center relative bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<!-- 背景图片 -->
<div v-if="imageUrl" class="absolute top-0 left-0 w-full h-full z-[1]">
<img :src="imageUrl" :alt="getTaskTypeName(currentTask?.task_type)" class="w-full h-full object-cover opacity-20 blur-sm">
</div>
<!-- 进度内容覆盖层 -->
<div class="absolute top-0 left-0 w-full h-full flex flex-col justify-center items-center z-[2] p-8 md:p-6 sm:p-4">
<div class="w-full max-w-[280px] text-center">
<!-- 进度条 -->
<div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(taskStatus)">
<div v-for="(subtask, index) in (currentTask?.subtasks || [])" :key="index">
<!-- PENDING状态:Apple 风格排队显示 -->
<div v-if="subtask.status === 'PENDING'" class="mt-4 text-center">
<div v-if="subtask.estimated_pending_order !== null && subtask.estimated_pending_order !== undefined && subtask.estimated_pending_order >= 0" class="flex flex-col items-center gap-3">
<!-- 排队图标 -->
<div class="flex flex-wrap justify-center gap-1.5 mb-2">
<i v-for="n in Math.min(Math.max(subtask.estimated_pending_order, 0), 10)"
:key="n"
class="fas fa-circle text-[8px] text-[#86868b] dark:text-[#98989d] opacity-60"></i>
<span v-if="subtask.estimated_pending_order > 10" class="text-xs text-[#86868b] dark:text-[#98989d] font-medium ml-0.5">
+{{ subtask.estimated_pending_order - 10 }}
</span>
</div>
<!-- 排队文字 -->
<span class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight">
{{ t('queuePosition') }}: {{ subtask.estimated_pending_order }}
</span>
</div>
</div>
<!-- RUNNING状态:Apple 风格进度条 -->
<div v-else-if="subtask.status === 'RUNNING'" class="w-full text-center">
<!-- 进度条 -->
<div class="mb-4">
<div class="relative w-full h-1 bg-black/8 dark:bg-white/8 rounded-full overflow-hidden">
<div class="absolute top-0 left-0 h-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full transition-all duration-500 ease-out" :style="{ width: getSubtaskProgress(subtask) + '%' }"></div>
</div>
</div>
<!-- 百分比显示 -->
<div class="flex justify-center items-center">
<span class="text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight animate-progress">
{{ getSubtaskProgress(subtask) }}%
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 失败:Apple 风格错误状态 -->
<div v-else-if="isFailed" class="w-full h-full flex flex-col items-center justify-center relative bg-[#fef2f2] dark:bg-[#2c1b1b]">
<!-- 背景图片 -->
<div v-if="imageUrl" class="absolute top-0 left-0 w-full h-full z-[1]">
<img :src="imageUrl" :alt="getTaskTypeName(currentTask?.task_type)" class="w-full h-full object-cover opacity-10 blur-sm">
</div>
<!-- 错误信息 -->
<div class="absolute top-0 left-0 w-full h-full flex flex-col justify-center items-center z-[2] p-8 md:p-6 sm:p-4">
<div class="w-12 h-12 rounded-full bg-red-500/10 dark:bg-red-400/10 flex items-center justify-center mb-4">
<i class="fas fa-exclamation-triangle text-2xl text-red-500 dark:text-red-400"></i>
</div>
<p class="text-[#1d1d1f] dark:text-[#f5f5f7] text-sm text-center font-medium tracking-tight">{{ t('videoGeneratingFailed') }}</p>
</div>
</div>
<!-- 已取消:Apple 风格取消状态 -->
<div v-else-if="isCancelled" class="w-full h-full flex flex-col items-center justify-center relative bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<!-- 背景图片 -->
<div v-if="imageUrl" class="absolute top-0 left-0 w-full h-full z-[1]">
<img :src="imageUrl" :alt="getTaskTypeName(currentTask?.task_type)" class="w-full h-full object-cover opacity-10 blur-sm">
</div>
<!-- 取消信息 -->
<div class="absolute top-0 left-0 w-full h-full flex flex-col justify-center items-center z-[2] p-8 md:p-6 sm:p-4">
<div class="w-12 h-12 rounded-full bg-black/5 dark:bg-white/5 flex items-center justify-center mb-4">
<i class="fas fa-ban text-2xl text-[#86868b] dark:text-[#98989d]"></i>
</div>
<p class="text-[#1d1d1f] dark:text-[#f5f5f7] text-sm text-center font-medium tracking-tight">{{ t('taskCancelled') }}</p>
</div>
</div>
<!-- 默认状态:Apple 风格 -->
<div v-else class="w-full h-full flex flex-col items-center justify-center relative bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<div class="w-16 h-16 rounded-full bg-black/5 dark:bg-white/5 flex items-center justify-center mb-4 z-[2]">
<i class="fas fa-video text-3xl text-[#86868b] dark:text-[#98989d]"></i>
</div>
<p class="text-[#86868b] dark:text-[#98989d] text-sm z-[2] tracking-tight">{{ t('videoNotAvailable') }}</p>
</div>
</div>
<!-- Apple 风格操作按钮 -->
<div class="flex justify-center gap-3">
<button
v-if="(isCompleted || isFailed || isCancelled) && currentTask?.task_id"
@click="deleteTask(currentTask.task_id, false)"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all.duration-200.ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-red-500 dark:text-red-400 hover:scale-105 active:scale-95"
:title="t('delete')">
<i class="fas fa-trash"></i>
</button>
<!-- 已完成:下载按钮 -->
<button
v-if="isCompleted && currentTask?.outputs?.output_video"
@click="handleDownloadFile(currentTask.task_id, 'output_video', currentTask.outputs.output_video)"
:disabled="downloadLoading"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all duration-200 ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[#1d1d1f] dark:text-[#f5f5f7]"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-105 active:scale-95'"
:title="t('download')">
<i class="fas fa-download"></i>
</button>
<!-- 已完成:分享按钮 -->
<button
v-if="isCompleted && currentTask?.outputs?.output_video"
@click="handleShareTask"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all duration-200 ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] hover:scale-105 active:scale-95"
:title="t('share')">
<i class="fas fa-share-alt"></i>
</button>
<!-- 进行中:取消按钮 -->
<button
v-if="isRunning"
@click="handleCancel"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all duration-200 ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-red-500 dark:text-red-400 hover:scale-105 active:scale-95"
:title="t('cancel')">
<i class="fas fa-times"></i>
</button>
<!-- 失败或取消:重试按钮 -->
<button
v-if="isFailed || isCancelled"
@click="handleRetry"
class="w-[40px] h-[40px] sm:w-[44px] sm:h-[44px] rounded-full flex items-center justify-center text-base transition-all duration-200 ease-out border-0 cursor-pointer bg-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] hover:scale-105 active:scale-95"
:title="t('retry')">
<i class="fas fa-redo"></i>
</button>
</div>
</div>
<!-- Apple 风格任务指示器 -->
<div v-if="sortedTasks.length > 1" class="flex justify-center gap-2 mt-5">
<div
v-for="(task, index) in sortedTasks"
:key="task.task_id"
@click="handleTaskIndicatorClick(task)"
class="w-2 h-2 rounded-full cursor-pointer transition-all duration-200 ease-out"
:class="index === currentTaskIndex
? 'bg-[#1d1d1f] dark:bg-[#f5f5f7] scale-110'
: 'bg-[#86868b]/30 dark:bg-[#98989d]/30 hover:bg-[#86868b]/50 dark:hover:bg-[#98989d]/50 hover:scale-105'">
</div>
</div>
</div>
</template>
<style scoped>
/* Apple 风格动画 */
@keyframes progress {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.85;
}
}
.animate-progress {
animation: progress 1.5s ease-in-out infinite;
}
/* 所有其他样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
</style>
<script setup>
import { ref, watch, onMounted, onUnmounted, computed, nextTick } from 'vue'
import { showTaskDetailModal,
modalTask,
closeTaskDetailModal,
cancelTask,
reuseTask,
handleDownloadFile,
deleteTask,
getTaskTypeName,
showFailureDetails,
formatTime,
getTaskStatusDisplay,
getStatusTextClass,
getProgressTitle,
getProgressInfo,
getOverallProgress,
getSubtaskStatusText,
getSubtaskProgress,
formatEstimatedTime,
generateShareUrl,
copyShareLink,
shareToSocial,
copyPrompt,
getTaskFileUrlSync,
getTaskFileFromCache,
getTaskFileUrl,
getTaskInputAudio,
downloadLoading,
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 audioElements = ref({}) // 使用对象存储多个音频元素,key 为 inputName
const audioStates = ref({}) // 存储每个音频的状态,key 为 inputName
const currentAudioUrl = ref('')
// 音频素材 URL(响应式,支持异步加载)
const audioMaterials = ref([])
// 获取图片素材
const getImageMaterials = () => {
if (!modalTask.value?.inputs?.input_image) return []
return [['input_image', getTaskFileUrlSync(modalTask.value.task_id, 'input_image')]]
}
// 获取视频素材
const getVideoMaterials = () => {
if (!modalTask.value?.inputs?.input_video) return []
return [['input_video', getTaskFileUrlSync(modalTask.value.task_id, 'input_video')]]
}
// 获取音频素材(使用响应式 ref)
const getAudioMaterials = () => {
return audioMaterials.value
}
// 根据任务类型获取应该显示的内容类型
const getVisibleMaterials = computed(() => {
if (!modalTask.value?.task_type) {
return { image: false, video: false, audio: false, prompt: false }
}
const taskType = modalTask.value.task_type
// 根据任务类型定义应该显示的内容
const visibilityMap = {
't2v': {
image: false,
video: false,
audio: false,
prompt: true
},
'i2v': {
image: true,
video: false,
audio: false,
prompt: true
},
's2v': {
image: true,
video: false,
audio: true,
prompt: true
},
'animate': {
image: true,
video: true,
audio: false,
prompt: false // animate 任务不显示 prompt
}
}
return visibilityMap[taskType] || {
image: true,
video: false,
audio: true,
prompt: true
}
})
// 异步加载音频素材 URL(支持目录模式)
const loadAudioMaterials = async () => {
if (!modalTask.value?.inputs?.input_audio) {
audioMaterials.value = []
return
}
try {
// 使用 getTaskInputAudio 来获取音频 URL,它会自动处理目录情况
const audioUrl = await getTaskInputAudio(modalTask.value)
if (audioUrl) {
audioMaterials.value = [['input_audio', audioUrl]]
} else {
audioMaterials.value = []
}
} catch (error) {
console.error('Failed to load audio materials:', error)
audioMaterials.value = []
}
}
// 路由关闭功能
const closeWithRoute = () => {
closeTaskDetailModal()
modalTask.value = null
// 只有当前路由是 /task/:id 时才进行路由跳转
// 如果在其他页面(如 /generate)打开的弹窗,关闭时保持在原页面
if (route.path.startsWith('/task/')) {
// 从任务详情路由进入的,返回到上一页或首页
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/')
}
}
// 如果不是任务详情路由,不做任何路由跳转,保持在当前页面
}
// 滚动到生成区域(仅在 generate 页面)
const scrollToCreationArea = () => {
const mainScrollable = document.querySelector('.main-scrollbar')
if (mainScrollable) {
mainScrollable.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
// 包装 reuseTask 函数,复用任务后回到生成区域
const handleReuseTask = () => {
const task = modalTask.value
if (!task) {
return
}
void reuseTask(task)
if (route.path === '/generate' || route.name === 'Generate') {
setTimeout(() => {
scrollToCreationArea()
}, 300)
}
}
// 键盘事件处理
const handleKeydown = (event) => {
if (event.key === 'Escape' && showTaskDetailModal.value) {
closeWithRoute()
}
}
// 获取文件扩展名
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, async (newTask) => {
if (newTask && !hasLoadedTask.value) {
console.log('modalTask第一次变化,加载任务详情:', newTask);
viewTaskDetail(newTask);
hasLoadedTask.value = true;
}
// 加载音频素材(支持目录模式)
if (newTask) {
await loadAudioMaterials();
}
}, { immediate: true });
// 生命周期钩子
onMounted(async () => {
document.addEventListener('keydown', handleKeydown)
console.log('TaskDetails组件已挂载,当前modalTask:', modalTask.value);
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
// 清理所有音频资源
Object.values(audioElements.value).forEach(audio => {
if (audio) {
audio.pause()
}
})
audioElements.value = {}
audioStates.value = {}
})
// 格式化音频时间
const formatAudioTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 设置音频元素 ref(安全版本)
const setAudioElement = (inputName, el) => {
if (!audioElements.value) {
audioElements.value = {}
}
if (el) {
audioElements.value[inputName] = el
} else if (audioElements.value[inputName]) {
// 元素被卸载时,清理 ref
delete audioElements.value[inputName]
}
}
// 获取音频元素
const getAudioElement = (inputName) => {
if (!audioElements.value) {
audioElements.value = {}
}
return audioElements.value[inputName]
}
// 获取音频状态
const getAudioState = (inputName) => {
if (!audioStates.value) {
audioStates.value = {}
}
if (!audioStates.value[inputName]) {
audioStates.value[inputName] = {
isPlaying: false,
duration: 0,
currentTime: 0,
isDragging: false
}
}
return audioStates.value[inputName]
}
// 切换播放/暂停
const toggleAudioPlayback = (inputName) => {
const audio = getAudioElement(inputName)
if (!audio) {
console.warn('Audio element not found for:', inputName)
return
}
const state = getAudioState(inputName)
if (audio.paused) {
audio.play().catch(error => {
console.error('播放失败:', error)
showAlert(t('audioPlaybackFailed') + ': ' + error.message, 'error')
})
} else {
audio.pause()
}
}
// 音频加载完成
const onAudioLoaded = (inputName) => {
const audio = getAudioElement(inputName)
const state = getAudioState(inputName)
if (audio && state) {
state.duration = audio.duration || 0
}
}
// 时间更新
const onTimeUpdate = (inputName) => {
const audio = getAudioElement(inputName)
const state = getAudioState(inputName)
if (audio && state && !state.isDragging) {
state.currentTime = audio.currentTime || 0
}
}
// 进度条变化处理
const onProgressChange = (event, inputName) => {
const audio = getAudioElement(inputName)
const state = getAudioState(inputName)
if (state && state.duration > 0 && audio && event.target) {
const newTime = parseFloat(event.target.value)
state.currentTime = newTime
audio.currentTime = newTime
}
}
// 进度条拖拽结束处理
const onProgressEnd = (event, inputName) => {
const audio = getAudioElement(inputName)
const state = getAudioState(inputName)
if (audio && state && state.duration > 0 && event.target) {
const newTime = parseFloat(event.target.value)
audio.currentTime = newTime
state.currentTime = newTime
}
if (state) {
state.isDragging = false
}
}
// 播放结束
const onAudioEnded = (inputName) => {
const state = getAudioState(inputName)
if (state) {
state.isPlaying = false
state.currentTime = 0
}
}
// 监听音频URL变化
watch(audioMaterials, (newMaterials) => {
if (newMaterials && newMaterials.length > 0) {
currentAudioUrl.value = newMaterials[0][1]
// 确保 audioStates.value 存在
if (!audioStates.value) {
audioStates.value = {}
}
// 为每个音频初始化状态
newMaterials.forEach(([inputName, url]) => {
if (!audioStates.value[inputName]) {
audioStates.value[inputName] = {
isPlaying: false,
duration: 0,
currentTime: 0,
isDragging: false
}
}
})
// 加载所有音频
nextTick(() => {
newMaterials.forEach(([inputName]) => {
const audio = getAudioElement(inputName)
if (audio) {
audio.load()
}
})
})
} else {
currentAudioUrl.value = ''
audioStates.value = {}
}
}, { immediate: true })
</script>
<template>
<!-- 任务详情弹窗 - Apple 极简风格 -->
<div v-cloak>
<div v-if="showTaskDetailModal"
class="fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-2 sm:p-1"
@click="closeWithRoute">
<!-- 任务完成时的大弹窗 - Apple 风格 -->
<div v-if="modalTask?.status === 'SUCCEED'"
class="w-full h-full max-w-7xl max-h-[100vh] bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] overflow-hidden flex flex-col" @click.stop>
<!-- 弹窗头部 - Apple 风格 -->
<div class="flex items-center justify-between px-8 py-5 border-b border-black/8 dark:border-white/8 bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[20px]">
<h3 class="text-xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight">
<i class="fas fa-check-circle text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
{{ t('taskDetail') }}
</h3>
<div class="flex items-center gap-2">
<button @click="closeWithRoute"
class="w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-red-500 dark:hover:text-red-400 hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('close')">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
<!-- 主要内容区域 - Apple 风格 -->
<div class="flex-1 overflow-y-auto main-scrollbar">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 p-8 lg:p-12">
<!-- 左侧视频区域 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px] aspect-[9/16] bg-black dark:bg-[#000000] rounded-2xl overflow-hidden shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.5)]">
<!-- 视频播放器 -->
<video
v-if="modalTask?.outputs?.output_video"
:src="getTaskFileUrlSync(modalTask.task_id, 'output_video')"
:poster="getTaskFileUrlSync(modalTask.task_id, 'input_image')"
class="w-full h-full object-contain"
controls
loop
preload="metadata"
@loadstart="onVideoLoadStart"
@canplay="onVideoCanPlay"
@error="onVideoError">
{{ t('browserNotSupported') }}
</video>
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<div class="w-16 h-16 rounded-full bg-black/5 dark:bg-white/5 flex items-center justify-center mb-4">
<i class="fas fa-video text-3xl text-[#86868b] dark:text-[#98989d]"></i>
</div>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('videoNotAvailable') }}</p>
</div>
</div>
</div>
<!-- 右侧信息区域 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px] aspect-[9/16] relative flex flex-col">
<!-- 居中的内容区域 -->
<div class="flex-1 flex items-center justify-center px-8 py-6">
<div class="w-full">
<div class="flex flex-col items-center gap-3 mb-6">
<div class="flex items-center gap-3">
<button @click="copyShareLink(modalTask.task_id, 'task')"
class="w-12 h-12 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-full shadow-[0_4px_16px_rgba(0,0,0,0.12)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.4)] hover:scale-110 active:scale-100 transition-all duration-200"
:title="t('share')">
<i class="fas fa-share-alt text-base"></i>
</button>
<button @click="deleteTask(modalTask.task_id, true)"
class="w-12 h-12 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/8 dark:border-white/8 text-red-500 dark:text-red-400 rounded-full shadow-[0_4px_16px_rgba(0,0,0,0.12)] dark:shadow-[0_4px_16px_rgba(0,0,0,0.4)] hover:scale-110 active:scale-100 transition-all duration-200"
:title="t('delete')">
<i class="fas fa-trash text-base"></i>
</button>
</div>
</div>
<!-- 标题 -->
<div class="text-center mb-6">
<h1 class="text-3xl sm:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 tracking-tight">
{{ t('taskCompleted') }}
</h1>
<p class="text-sm sm:text-base text-[#86868b] dark:text-[#98989d] tracking-tight">
{{ t('taskCompletedSuccessfully') }}
</p>
</div>
<!-- 特性列表 - Apple 风格 -->
<div class="grid grid-cols-3 gap-2 mb-6">
<div class="flex flex-col items-center gap-1.5 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl">
<i class="fas fa-toolbox text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-[11px] text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight">{{ getTaskTypeName(modalTask) }}</span>
</div>
<div class="flex flex-col items-center gap-1.5 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl">
<i class="fas fa-robot text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-[11px] text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight truncate max-w-full">{{ modalTask.model_cls }}</span>
</div>
<div class="flex flex-col items-center gap-1.5 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl">
<i class="fas fa-clock text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-[11px] text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight">{{ Math.round(modalTask.extra_info?.active_elapse || 0) }}s</span>
</div>
</div>
<!-- 操作按钮 - Apple 风格 -->
<div class="space-y-2.5">
<button v-if="modalTask?.outputs?.output_video"
@click="handleDownloadFile(modalTask.task_id, 'output_video', modalTask.outputs.output_video)"
:disabled="downloadLoading"
class="w-full rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border-0 px-6 py-3 text-[15px] font-semibold text-white transition-all duration-200 ease-out tracking-tight flex items-center justify-center gap-2"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-[1.02] hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.35)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100'">
<i class="fas fa-download text-sm"></i>
<span>{{ t('downloadVideo') }}</span>
</button>
<button @click="handleReuseTask"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-magic text-sm"></i>
<span>{{ t('reuseTask') }}</span>
</button>
<button @click="showDetails = !showDetails"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i :class="showDetails ? 'fas fa-chevron-up' : 'fas fa-info-circle'" class="text-sm"></i>
<span>{{ showDetails ? t('hideDetails') : t('showDetails') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 详细信息面板 - Apple 风格 (成功状态)-->
<div v-if="showDetails && modalTask" class="bg-[#f5f5f7] dark:bg-[#1c1c1e] border-t border-black/8 dark:border-white/8 py-12">
<div class="max-w-6xl mx-auto px-8">
<!-- 输入素材标题 - Apple 风格 -->
<h2 class="text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center justify-center gap-3 mb-8 tracking-tight">
<i class="fas fa-upload text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ t('inputMaterials') }}</span>
</h2>
<!-- 根据任务类型显示相应的素材卡片 - Apple 风格 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- 图片卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.image" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-image text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('image') }}</h3>
</div>
<button v-if="getImageMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_image', modalTask.inputs.input_image)"
:disabled="downloadLoading"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getImageMaterials().length > 0">
<div v-for="[inputName, url] in getImageMaterials()" :key="inputName" class="rounded-xl overflow-hidden border border-black/8 dark:border-white/8">
<img :src="url" :alt="inputName" class="w-full h-auto object-contain">
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-image text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noImage') }}</p>
</div>
</div>
</div>
<!-- 视频卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.video" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-video text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('video') }}</h3>
</div>
<button v-if="getVideoMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_video', modalTask.inputs.input_video)"
:disabled="downloadLoading"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getVideoMaterials().length > 0">
<div v-for="[inputName, url] in getVideoMaterials()" :key="inputName" class="rounded-xl overflow-hidden border border-black/8 dark:border-white/8">
<video :src="url" :alt="inputName" class="w-full h-auto object-contain" controls preload="metadata">
{{ t('browserNotSupported') }}
</video>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-video text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noVideo') }}</p>
</div>
</div>
</div>
<!-- 音频卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.audio" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-music text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('audio') }}</h3>
</div>
<button v-if="getAudioMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_audio', modalTask.inputs.input_audio)"
:disabled="downloadLoading"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getAudioMaterials().length > 0" class="space-y-4">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName">
<!-- 音频播放器卡片 - Apple 风格 -->
<div class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] w-full p-4">
<div class="relative flex items-center mb-3">
<!-- 头像容器 -->
<div class="relative mr-3 flex-shrink-0">
<!-- 透明白色头像 -->
<div class="w-12 h-12 rounded-full bg-white/40 dark:bg-white/20 border border-white/30 dark:border-white/20 transition-all duration-200"></div>
<!-- 播放/暂停按钮 -->
<button
@click="toggleAudioPlayback(inputName)"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 rounded-full flex items-center justify-center text-white cursor-pointer hover:scale-110 transition-all duration-200 z-20 shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)]"
>
<i :class="getAudioState(inputName).isPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-xs ml-0.5"></i>
</button>
</div>
<!-- 音频信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate">
{{ t('audio') }}
</div>
</div>
<!-- 音频时长 -->
<div class="text-xs font-medium text-[#86868b] dark:text-[#98989d] tracking-tight flex-shrink-0">
{{ formatAudioTime(getAudioState(inputName).currentTime) }} / {{ formatAudioTime(getAudioState(inputName).duration) }}
</div>
</div>
<!-- 进度条 -->
<div class="flex items-center gap-2" v-if="getAudioState(inputName).duration > 0">
<input
type="range"
:min="0"
:max="getAudioState(inputName).duration"
:value="getAudioState(inputName).currentTime"
@input="(e) => onProgressChange(e, inputName)"
@change="(e) => onProgressChange(e, inputName)"
@mousedown="getAudioState(inputName).isDragging = true"
@mouseup="(e) => onProgressEnd(e, inputName)"
@touchstart="getAudioState(inputName).isDragging = true"
@touchend="(e) => onProgressEnd(e, inputName)"
class="flex-1 h-1 bg-black/6 dark:bg-white/15 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-[color:var(--brand-primary)] dark:[&::-webkit-slider-thumb]:bg-[color:var(--brand-primary-light)] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
</div>
<!-- 隐藏的音频元素 -->
<audio
:ref="(el) => setAudioElement(inputName, el)"
:src="url"
@loadedmetadata="() => onAudioLoaded(inputName)"
@timeupdate="() => onTimeUpdate(inputName)"
@ended="() => onAudioEnded(inputName)"
@play="() => getAudioState(inputName).isPlaying = true"
@pause="() => getAudioState(inputName).isPlaying = false"
@error="(e) => { console.error('Audio error:', e, url); showAlert(t('audioLoadFailed'), 'error') }"
preload="metadata"
class="hidden"
></audio>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-music text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noAudio') }}</p>
</div>
</div>
</div>
<!-- 提示词卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.prompt" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-file-alt text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('prompt') }}</h3>
</div>
<button v-if="modalTask?.params?.prompt"
@click="copyPrompt(modalTask?.params?.prompt)"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('copy')">
<i class="fas fa-copy text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="modalTask?.params?.prompt" class="bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[10px] border border-black/6 dark:border-white/6 rounded-xl p-4">
<p class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] leading-relaxed tracking-tight break-words">{{ modalTask.params.prompt }}</p>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-file-alt text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noPrompt') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 其他状态的弹窗 - Apple 风格 -->
<div v-else class="w-full max-w-7xl max-h-[95vh] bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] overflow-hidden flex flex-col" @click.stop>
<!-- 弹窗头部 - Apple 风格 -->
<div class="flex items-center justify-between px-8 py-5 border-b border-black/8 dark:border-white/8 bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[20px]">
<h3 class="text-xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight">
<i v-if="modalTask?.status === 'FAILED'" class="fas fa-exclamation-triangle text-red-500 dark:text-red-400"></i>
<i v-else-if="modalTask?.status === 'CANCEL'" class="fas fa-ban text-[#86868b] dark:text-[#98989d]"></i>
<i v-else class="fas fa-spinner fa-spin text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ t('taskDetail') }}</span>
</h3>
<div class="flex items-center gap-2">
<button @click="closeWithRoute"
class="w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-red-500 dark:hover:text-red-400 hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('close')">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
<!-- 主要内容区域 - Apple 风格 -->
<div class="flex-1 overflow-y-auto main-scrollbar">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 p-8 lg:p-12">
<!-- 左侧占位图区域 - Apple 风格 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px] aspect-[9/16] bg-black dark:bg-[#000000] rounded-2xl overflow-hidden shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.5)] relative">
<!-- 根据状态显示不同的占位图 -->
<!-- 进行中状态 -->
<div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)" class="w-full h-full flex flex-col items-center justify-center relative bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<!-- 如果有图像输入,显示为背景 -->
<div v-if="getImageMaterials().length > 0" class="absolute top-0 left-0 w-full h-full z-[1]">
<img :src="getImageMaterials()[0][1]" :alt="getImageMaterials()[0][0]" class="w-full h-full object-cover opacity-20 blur-sm">
</div>
<div class="absolute top-0 left-0 w-full h-full flex flex-col justify-center items-center z-[2]">
<div class="relative w-12 h-12 mb-6">
<div class="absolute inset-0 rounded-full border-2 border-black/8 dark:border-white/8"></div>
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-[color:var(--brand-primary)] dark:border-t-[color:var(--brand-primary-light)] animate-spin"></div>
</div>
<p class="text-sm font-medium text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('videoGenerating') }}...</p>
</div>
</div>
<!-- 失败状态 -->
<div v-else-if="modalTask?.status === 'FAILED'" class="w-full h-full flex flex-col items-center justify-center relative bg-[#fef2f2] dark:bg-[#2c1b1b]">
<div v-if="getImageMaterials().length > 0" class="absolute top-0 left-0 w-full h-full z-[1]">
<img :src="getImageMaterials()[0][1]" :alt="getImageMaterials()[0][0]" class="w-full h-full object-cover opacity-10 blur-sm">
</div>
<div class="absolute top-0 left-0 w-full h-full flex flex-col justify-center items-center z-[2]">
<div class="w-16 h-16 rounded-full bg-red-500/10 dark:bg-red-400/10 flex items-center justify-center mb-4">
<i class="fas fa-exclamation-triangle text-3xl text-red-500 dark:text-red-400"></i>
</div>
<p class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('videoGeneratingFailed') }}</p>
</div>
</div>
<!-- 取消状态 -->
<div v-else-if="modalTask?.status === 'CANCEL'" class="w-full h-full flex flex-col items-center justify-center relative bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<div v-if="getImageMaterials().length > 0" class="absolute top-0 left-0 w-full h-full z-[1]">
<img :src="getImageMaterials()[0][1]" :alt="getImageMaterials()[0][0]" class="w-full h-full object-cover opacity-10 blur-sm">
</div>
<div class="absolute top-0 left-0 w-full h-full flex flex-col justify-center items-center z-[2]">
<div class="w-16 h-16 rounded-full bg-black/5 dark:bg-white/5 flex items-center justify-center mb-4">
<i class="fas fa-ban text-3xl text-[#86868b] dark:text-[#98989d]"></i>
</div>
<p class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('taskCancelled') }}</p>
</div>
</div>
</div>
</div>
<!-- 右侧信息区域 - Apple 风格 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px] aspect-[9/16] relative flex flex-col">
<!-- 右上角删除按钮 - Apple 极简风格 -->
<div class="absolute top-0 right-0 z-10">
<button v-if="['FAILED', 'CANCEL'].includes(modalTask?.status)"
@click="deleteTask(modalTask.task_id, true)"
class="w-8 h-8 flex items-center justify-center bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[20px] border border-black/10 dark:border-white/10 rounded-full shadow-[0_2px_8px_rgba(0,0,0,0.12)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 hover:scale-110 hover:shadow-[0_4px_12px_rgba(239,68,68,0.2)] dark:hover:shadow-[0_4px_12px_rgba(248,113,113,0.3)] active:scale-100 transition-all duration-200"
:title="t('delete')">
<i class="fas fa-trash text-xs"></i>
</button>
</div>
<!-- 居中的内容区域 -->
<div class="flex-1 flex items-center justify-center px-8 py-6">
<div class="w-full">
<!-- 标题和状态 - Apple 风格 -->
<div class="text-center mb-6">
<h1 class="text-3xl sm:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-3 tracking-tight">
<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('taskDetail') }}</span>
</h1>
</div>
<!-- 进度条 - Apple 风格 -->
<div v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)" class="mb-6">
<div v-for="(subtask, index) in (modalTask.subtasks || [])" :key="index">
<!-- PENDING状态:Apple 风格排队显示 -->
<div v-if="subtask.status === 'PENDING'" class="text-center">
<div v-if="subtask.estimated_pending_order !== null && subtask.estimated_pending_order !== undefined && subtask.estimated_pending_order >= 0" class="flex flex-col items-center gap-3">
<!-- 排队图标 -->
<div class="flex flex-wrap justify-center gap-1.5 mb-2">
<i v-for="n in Math.min(Math.max(subtask.estimated_pending_order, 0), 10)"
:key="n"
class="fas fa-circle text-[8px] text-[#86868b] dark:text-[#98989d] opacity-60"></i>
<span v-if="subtask.estimated_pending_order > 10" class="text-xs text-[#86868b] dark:text-[#98989d] font-medium ml-0.5">
+{{ subtask.estimated_pending_order - 10 }}
</span>
</div>
<!-- 排队文字 -->
<span class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight">
{{ t('queuePosition') }}: {{ subtask.estimated_pending_order }}
</span>
</div>
</div>
<!-- RUNNING状态:Apple 风格进度条 -->
<div v-else-if="subtask.status === 'RUNNING'" class="w-full">
<!-- 进度条 -->
<div class="mb-4">
<div class="relative w-full h-1 bg-black/8 dark:bg-white/8 rounded-full overflow-hidden">
<div class="absolute top-0 left-0 h-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full transition-all duration-500 ease-out" :style="{ width: getSubtaskProgress(subtask) + '%' }"></div>
</div>
</div>
<!-- 百分比显示 -->
<div class="flex justify-center items-center">
<span class="text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">
{{ getSubtaskProgress(subtask) }}%
</span>
</div>
</div>
</div>
</div>
<!-- 描述 - Apple 风格 -->
<div class="text-sm sm:text-base text-[#86868b] dark:text-[#98989d] text-center mb-6 tracking-tight">
<p v-if="['RUNNING'].includes(modalTask?.status)" class="mb-0">
{{ t('aiIsGeneratingYourVideo') }}
</p>
<p v-else-if="['CREATED'].includes(modalTask?.status)" class="mb-0">
{{ t('taskSubmittedSuccessfully') }}
</p>
<p v-else-if="['PENDING'].includes(modalTask?.status)" class="mb-0">
{{ t('taskQueuePleaseWait') }}
</p>
<div v-else-if="modalTask?.status === 'FAILED'">
<p class="mb-4">{{ t('sorryYourVideoGenerationTaskFailed') }}</p>
<button v-if="modalTask?.fail_msg"
@click="showFailureDetails = !showFailureDetails"
class="text-sm text-[#86868b] dark:text-[#98989d] hover:text-red-500 dark:hover:text-red-400 transition-colors underline underline-offset-2">
{{ showFailureDetails ? t('hideDetails') : t('viewErrorDetails') }}
</button>
<div v-if="showFailureDetails && modalTask?.fail_msg" class="mt-4 p-4 bg-black/2 dark:bg-white/2 border border-black/6 dark:border-white/6 rounded-xl text-left">
<p class="text-xs text-[#86868b] dark:text-[#98989d] whitespace-pre-wrap leading-relaxed">{{ modalTask?.fail_msg }}</p>
</div>
</div>
<p v-else-if="modalTask?.status === 'CANCEL'" class="mb-0">
{{ t('thisTaskHasBeenCancelledYouCanRegenerateOrViewTheMaterialsYouUploadedBefore') }}
</p>
</div>
<!-- 特性列表 - Apple 风格 -->
<div class="grid grid-cols-2 gap-2 mb-6">
<div class="flex flex-col items-center gap-1.5 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl">
<i class="fas fa-toolbox text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-[11px] text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight">{{ getTaskTypeName(modalTask) }}</span>
</div>
<div class="flex flex-col items-center gap-1.5 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl">
<i class="fas fa-robot text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-[11px] text-[#1d1d1f] dark:text-[#f5f5f7] font-medium tracking-tight truncate max-w-full">{{ modalTask.model_cls }}</span>
</div>
</div>
<!-- 操作按钮 - Apple 风格 -->
<div class="space-y-2.5">
<!-- 进行中状态:取消按钮 -->
<button v-if="['CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
@click="cancelTask(modalTask.task_id, true)"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-3 text-[15px] font-semibold text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-500/30 dark:hover:border-red-400/30 hover:shadow-[0_8px_24px_rgba(239,68,68,0.2)] dark:hover:shadow-[0_8px_24px_rgba(248,113,113,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-times text-sm"></i>
<span>{{ t('cancelTask') }}</span>
</button>
<!-- 失败或取消状态:重试按钮 -->
<button v-if="modalTask?.status === 'FAILED' || modalTask?.status === 'CANCEL'"
@click="resumeTask(modalTask.task_id, true)"
class="w-full rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border-0 px-6 py-3 text-[15px] font-semibold text-white hover:scale-[1.02] hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.35)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100 transition-all duration-200 ease-out tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-redo text-sm"></i>
<span>{{ modalTask?.status === 'CANCEL' ? t('regenerateTask') : t('retryTask') }}</span>
</button>
<!-- 通用按钮 -->
<button v-if="['SUCCEED', 'FAILED', 'CANCEL','CREATED', 'PENDING', 'RUNNING'].includes(modalTask?.status)"
@click="handleReuseTask"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-copy text-sm"></i>
<span>{{ t('reuseTask') }}</span>
</button>
<button @click="showDetails = !showDetails"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i :class="showDetails ? 'fas fa-chevron-up' : 'fas fa-info-circle'" class="text-sm"></i>
<span>{{ showDetails ? t('hideDetails') : t('showDetails') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 详细信息面板 - Apple 风格(其他状态)-->
<div v-if="showDetails && modalTask" class="bg-[#f5f5f7] dark:bg-[#1c1c1e] border-t border-black/8 dark:border-white/8 py-12">
<div class="max-w-6xl mx-auto px-8">
<!-- 输入素材标题 - Apple 风格 -->
<h2 class="text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center justify-center gap-3 mb-8 tracking-tight">
<i class="fas fa-upload text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ t('inputMaterials') }}</span>
</h2>
<!-- 根据任务类型显示相应的素材卡片 - Apple 风格 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- 图片卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.image" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-image text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('image') }}</h3>
</div>
<button v-if="getImageMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_image', modalTask.inputs.input_image)"
:disabled="downloadLoading"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getImageMaterials().length > 0">
<div v-for="[inputName, url] in getImageMaterials()" :key="inputName" class="rounded-xl overflow-hidden border border-black/8 dark:border-white/8">
<img :src="url" :alt="inputName" class="w-full h-auto object-contain">
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-image text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noImage') }}</p>
</div>
</div>
</div>
<!-- 视频卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.video" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-video text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('video') }}</h3>
</div>
<button v-if="getVideoMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_video', modalTask.inputs.input_video)"
:disabled="downloadLoading"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getVideoMaterials().length > 0">
<div v-for="[inputName, url] in getVideoMaterials()" :key="inputName" class="rounded-xl overflow-hidden border border-black/8 dark:border-white/8">
<video :src="url" :alt="inputName" class="w-full h-auto object-contain" controls preload="metadata">
{{ t('browserNotSupported') }}
</video>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-video text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noVideo') }}</p>
</div>
</div>
</div>
<!-- 音频卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.audio" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-music text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('audio') }}</h3>
</div>
<button v-if="getAudioMaterials().length > 0"
@click="handleDownloadFile(modalTask.task_id, 'input_audio', modalTask.inputs.input_audio)"
:disabled="downloadLoading"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200"
:class="downloadLoading ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 active:scale-100'"
:title="t('download')">
<i class="fas fa-download text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getAudioMaterials().length > 0" class="space-y-4">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName">
<!-- 音频播放器卡片 - Apple 风格 -->
<div class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] w-full p-4">
<div class="relative flex items-center mb-3">
<!-- 头像容器 -->
<div class="relative mr-3 flex-shrink-0">
<!-- 透明白色头像 -->
<div class="w-12 h-12 rounded-full bg-white/40 dark:bg-white/20 border border-white/30 dark:border-white/20 transition-all duration-200"></div>
<!-- 播放/暂停按钮 -->
<button
@click="toggleAudioPlayback(inputName)"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 rounded-full flex items-center justify-center text-white cursor-pointer hover:scale-110 transition-all duration-200 z-20 shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)]"
>
<i :class="getAudioState(inputName).isPlaying ? 'fas fa-pause' : 'fas fa-play'" class="text-xs ml-0.5"></i>
</button>
</div>
<!-- 音频信息 -->
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate">
{{ t('audio') }}
</div>
</div>
<!-- 音频时长 -->
<div class="text-xs font-medium text-[#86868b] dark:text-[#98989d] tracking-tight flex-shrink-0">
{{ formatAudioTime(getAudioState(inputName).currentTime) }} / {{ formatAudioTime(getAudioState(inputName).duration) }}
</div>
</div>
<!-- 进度条 -->
<div class="flex items-center gap-2" v-if="getAudioState(inputName).duration > 0">
<input
type="range"
:min="0"
:max="getAudioState(inputName).duration"
:value="getAudioState(inputName).currentTime"
@input="(e) => onProgressChange(e, inputName)"
@change="(e) => onProgressChange(e, inputName)"
@mousedown="getAudioState(inputName).isDragging = true"
@mouseup="(e) => onProgressEnd(e, inputName)"
@touchstart="getAudioState(inputName).isDragging = true"
@touchend="(e) => onProgressEnd(e, inputName)"
class="flex-1 h-1 bg-black/6 dark:bg-white/15 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-[color:var(--brand-primary)] dark:[&::-webkit-slider-thumb]:bg-[color:var(--brand-primary-light)] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer"
/>
</div>
</div>
<!-- 隐藏的音频元素 -->
<audio
:ref="(el) => setAudioElement(inputName, el)"
:src="url"
@loadedmetadata="() => onAudioLoaded(inputName)"
@timeupdate="() => onTimeUpdate(inputName)"
@ended="() => onAudioEnded(inputName)"
@play="() => getAudioState(inputName).isPlaying = true"
@pause="() => getAudioState(inputName).isPlaying = false"
@error="(e) => { console.error('Audio error:', e, url); showAlert(t('audioLoadFailed'), 'error') }"
preload="metadata"
class="hidden"
></audio>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-music text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noAudio') }}</p>
</div>
</div>
</div>
<!-- 提示词卡片 - Apple 风格 -->
<div v-if="getVisibleMaterials.prompt" class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-file-alt text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('prompt') }}</h3>
</div>
<button v-if="modalTask?.params?.prompt"
@click="copyPrompt(modalTask?.params?.prompt)"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('copy')">
<i class="fas fa-copy text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="modalTask?.params?.prompt" class="bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[10px] border border-black/6 dark:border-white/6 rounded-xl p-4">
<p class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] leading-relaxed tracking-tight break-words">{{ modalTask.params.prompt }}</p>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-file-alt text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noPrompt') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Apple 风格极简样式 - 所有样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
</style>
<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 []
const imageUrl = getTemplateFileUrl(selectedTemplate.value.inputs.input_image, 'images')
if (!imageUrl) return []
return [['input_image', imageUrl]]
}
// 获取音频素材
const getAudioMaterials = () => {
if (!selectedTemplate.value?.inputs?.input_audio) return []
const audioUrl = getTemplateFileUrl(selectedTemplate.value.inputs.input_audio, 'audios')
if (!audioUrl) return []
return [['input_audio', audioUrl]]
}
// 路由关闭功能
const closeWithRoute = () => {
closeTemplateDetailModal()
// 只有当前路由是模板详情页面时才进行路由跳转
// 如果在其他页面(如 generate)打开的弹窗,关闭时保持在原页面
if (route.path.startsWith('/template/')) {
// 从模板详情路由进入的,返回到上一页或首页
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/')
}
}
// 如果不是模板详情路由,不做任何路由跳转,保持在当前页面
}
// 滚动到生成区域(仅在 generate 页面)
const scrollToCreationArea = () => {
const creationArea = document.querySelector('#task-creator')
if (creationArea) {
creationArea.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
}
}
// 包装 useTemplate 函数,在 generate 页面时滚动到生成区域
const handleUseTemplate = () => {
const template = selectedTemplate.value
if (!template) {
return
}
void useTemplate(template)
// 如果当前在 generate 页面,滚动到生成区域
if (route.path === '/generate' || route.name === 'Generate') {
// 等待 DOM 更新和展开动画完成
setTimeout(() => {
scrollToCreationArea()
}, 300)
}
}
// 键盘事件处理
const handleKeydown = (event) => {
if (event.key === 'Escape' && showTemplateDetailModal.value) {
closeWithRoute()
}
}
// 生命周期钩子
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<!-- 模板详情弹窗 - Apple 极简风格 -->
<div v-cloak>
<div v-if="showTemplateDetailModal"
class="fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-2 sm:p-1"
@click="closeWithRoute">
<div class="w-full h-full max-w-7xl max-h-[100vh] bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] overflow-hidden flex flex-col" @click.stop>
<!-- 弹窗头部 - Apple 风格 -->
<div class="flex items-center justify-between px-8 py-5 border-b border-black/8 dark:border-white/8 bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[20px]">
<h3 class="text-xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center gap-3 tracking-tight">
<i class="fas fa-star text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
{{ t('templateDetail') }}
</h3>
<div class="flex items-center gap-2">
<button @click="closeWithRoute"
class="w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-red-500 dark:hover:text-red-400 hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('close')">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
<!-- 主要内容区域 - Apple 风格 -->
<div class="flex-1 overflow-y-auto main-scrollbar">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 p-8 lg:p-12">
<!-- 左侧视频区域 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px] aspect-[9/16] bg-black dark:bg-[#000000] rounded-2xl overflow-hidden shadow-[0_8px_24px_rgba(0,0,0,0.15)] dark:shadow-[0_8px_24px_rgba(0,0,0,0.5)]">
<!-- 视频播放器 -->
<video
v-if="selectedTemplate?.outputs?.output_video"
:src="getTemplateFileUrl(selectedTemplate.outputs.output_video,'videos')"
:poster="selectedTemplate?.inputs?.input_image ? getTemplateFileUrl(selectedTemplate.inputs.input_image,'images') : undefined"
class="w-full h-full object-contain"
controls
loop
preload="metadata"
@loadeddata="onVideoLoaded">
{{ t('browserNotSupported') }}
</video>
<div v-else class="w-full h-full flex flex-col items-center justify-center bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<div class="w-16 h-16 rounded-full bg-black/5 dark:bg-white/5 flex items-center justify-center mb-4">
<i class="fas fa-video text-3xl text-[#86868b] dark:text-[#98989d]"></i>
</div>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('videoNotAvailable') }}</p>
</div>
</div>
</div>
<!-- 右侧信息区域 - Apple 风格 -->
<div class="flex items-center justify-center">
<div class="w-full max-w-[400px]">
<!-- 标题 - Apple 风格 -->
<h1 class="text-3xl sm:text-4xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] mb-4 tracking-tight">
{{ t('template') }}
</h1>
<!-- 描述 - Apple 风格 -->
<p class="text-sm sm:text-base text-[#86868b] dark:text-[#98989d] mb-8 tracking-tight">
{{ t('templateDescription') }}
</p>
<!-- 快速操作 - Apple 风格 -->
<div class="grid grid-cols-2 gap-2 mb-8">
<button @click="applyTemplateImage(selectedTemplate)"
class="flex items-center gap-2 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)] active:scale-[0.98]">
<div class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-lg flex-shrink-0">
<i class="fas fa-image text-sm text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
</div>
<span class="text-xs font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('onlyUseImage') }}</span>
</button>
<button @click="applyTemplateAudio(selectedTemplate)"
class="flex items-center gap-2 p-3 bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:shadow-[0_4px_12px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_4px_12px_rgba(var(--brand-primary-light-rgb),0.2)] active:scale-[0.98]">
<div class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-lg flex-shrink-0">
<i class="fas fa-music text-sm text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
</div>
<span class="text-xs font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('onlyUseAudio') }}</span>
</button>
</div>
<!-- 操作按钮 - Apple 风格 -->
<div class="space-y-2.5">
<button @click="handleUseTemplate"
class="w-full rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] border-0 px-6 py-3 text-[15px] font-semibold text-white hover:scale-[1.02] hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.35)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.4)] active:scale-100 transition-all duration-200 ease-out tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-magic text-sm"></i>
<span>{{ t('useTemplate') }}</span>
</button>
<button @click="copyShareLink(selectedTemplate?.task_id, 'template')"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i class="fas fa-share-alt text-sm"></i>
<span>{{ t('shareTemplate') }}</span>
</button>
<button @click="showDetails = !showDetails"
class="w-full rounded-full bg-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 px-6 py-2.5 text-[15px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7] hover:bg-white/80 dark:hover:bg-[#3a3a3c]/80 hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.3)] active:scale-[0.98] transition-all duration-200 tracking-tight flex items-center justify-center gap-2">
<i :class="showDetails ? 'fas fa-chevron-up' : 'fas fa-info-circle'" class="text-sm"></i>
<span>{{ showDetails ? t('hideDetails') : t('showDetails') }}</span>
</button>
</div>
<!-- 技术信息 - Apple 风格 -->
<div class="text-center pt-6 mt-6 border-t border-black/8 dark:border-white/8">
<a href="https://github.com/ModelTC/LightX2V"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm text-[#86868b] dark:text-[#98989d] hover:text-[color:var(--brand-primary)] dark:hover:text-[color:var(--brand-primary-light)] transition-colors tracking-tight">
<i class="fab fa-github text-base"></i>
<span>{{ t('poweredByLightX2V') }}</span>
<i class="fas fa-external-link-alt text-xs"></i>
</a>
</div>
</div>
</div>
</div>
<!-- 详细信息面板 - Apple 风格 -->
<div v-if="showDetails && selectedTemplate" class="bg-[#f5f5f7] dark:bg-[#1c1c1e] border-t border-black/8 dark:border-white/8 py-12">
<div class="max-w-6xl mx-auto px-8">
<!-- 输入素材标题 - Apple 风格 -->
<h2 class="text-2xl font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] flex items-center justify-center gap-3 mb-8 tracking-tight">
<i class="fas fa-upload text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ t('inputMaterials') }}</span>
</h2>
<!-- 三个并列的分块卡片 - Apple 风格 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- 图片卡片 - Apple 风格 -->
<div class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-image text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('image') }}</h3>
</div>
<button v-if="selectedTemplate?.inputs?.input_image"
@click="applyTemplateImage(selectedTemplate)"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('applyImage')">
<i class="fas fa-magic text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getImageMaterials().length > 0">
<div v-for="[inputName, url] in getImageMaterials()" :key="inputName"
class="rounded-xl overflow-hidden border border-black/8 dark:border-white/8 cursor-pointer hover:border-[color:var(--brand-primary)]/50 dark:hover:border-[color:var(--brand-primary-light)]/50 transition-all duration-200"
@click="showImageZoom(url)">
<img :src="url" :alt="inputName" class="w-full h-auto object-contain">
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-image text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noImage') }}</p>
</div>
</div>
</div>
<!-- 音频卡片 - Apple 风格 -->
<div class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-music text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('audio') }}</h3>
</div>
<button v-if="selectedTemplate?.inputs?.input_audio"
@click="applyTemplateAudio(selectedTemplate)"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('applyAudio')">
<i class="fas fa-magic text-xs"></i>
</button>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="getAudioMaterials().length > 0" class="space-y-4">
<div v-for="[inputName, url] in getAudioMaterials()" :key="inputName">
<audio :src="url" controls class="w-full rounded-xl"></audio>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-music text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noAudio') }}</p>
</div>
</div>
</div>
<!-- 提示词卡片 - Apple 风格 -->
<div class="bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-2xl overflow-hidden transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_8px_24px_rgba(0,0,0,0.1)] dark:hover:shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
<!-- 卡片头部 -->
<div class="flex items-center justify-between px-5 py-4 bg-[color:var(--brand-primary)]/5 dark:bg-[color:var(--brand-primary-light)]/10 border-b border-black/8 dark:border-white/8">
<div class="flex items-center gap-3">
<i class="fas fa-file-alt text-lg text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<h3 class="text-base font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('prompt') }}</h3>
</div>
<div class="flex items-center gap-1">
<button v-if="selectedTemplate?.params?.prompt"
@click="copyPrompt(selectedTemplate?.params?.prompt)"
class="w-8 h-8 flex items-center justify-center bg-[#86868b]/10 dark:bg-[#98989d]/15 border border-[#86868b]/20 dark:border-[#98989d]/20 text-[#86868b] dark:text-[#98989d] rounded-lg transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('copy')">
<i class="fas fa-copy text-xs"></i>
</button>
<button v-if="selectedTemplate?.params?.prompt"
@click="applyTemplatePrompt(selectedTemplate)"
class="w-8 h-8 flex items-center justify-center bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 border border-[color:var(--brand-primary)]/20 dark:border-[color:var(--brand-primary-light)]/20 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded-lg transition-all duration-200 hover:scale-110 active:scale-100"
:title="t('applyPrompt')">
<i class="fas fa-magic text-xs"></i>
</button>
</div>
</div>
<!-- 卡片内容 -->
<div class="p-6 min-h-[200px]">
<div v-if="selectedTemplate?.params?.prompt" class="bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[10px] border border-black/6 dark:border-white/6 rounded-xl p-4">
<p class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] leading-relaxed tracking-tight break-words">{{ selectedTemplate.params.prompt }}</p>
</div>
<div v-else class="flex flex-col items-center justify-center h-[150px]">
<i class="fas fa-file-alt text-3xl text-[#86868b]/30 dark:text-[#98989d]/30 mb-3"></i>
<p class="text-sm text-[#86868b] dark:text-[#98989d] tracking-tight">{{ t('noPrompt') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 所有样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
/* Apple 风格极简黑白设计 */
</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>
<!-- Apple 极简风格模板展示 -->
<div class="template-display">
<!-- 瀑布流布局 - Apple 风格 -->
<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-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.2)]">
<!-- 视频缩略图区域 -->
<div class="cursor-pointer bg-black/2 dark:bg-white/2 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="item?.inputs?.input_image ? getTemplateFileUrl(item.inputs.input_image,'images') : undefined"
class="w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
preload="auto" playsinline webkit-playsinline
@mouseenter="playVideo($event)" @mouseleave="pauseVideo($event)"
@loadeddata="onVideoLoaded($event)"
@ended="onVideoEnded($event)"
@error="onVideoError($event)"></video>
<!-- 图片缩略图 -->
<img v-else-if="item?.inputs?.input_image"
:src="getTemplateFileUrl(item.inputs.input_image,'images')"
:alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
@error="handleThumbnailError" />
<!-- 如果没有图片,显示占位符 -->
<div v-else class="w-full h-[200px] flex items-center justify-center bg-[#f5f5f7] dark:bg-[#1c1c1e]">
<i class="fas fa-image text-3xl text-[#86868b]/30 dark:text-[#98989d]/30"></i>
</div>
<!-- 移动端播放按钮 - Apple 风格 -->
<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-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 transition-all duration-200 z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 悬浮操作按钮(仅当 showActions 为 true 时显示)- Apple 风格 -->
<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 gap-2 pointer-events-auto">
<button @click.stop="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
: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-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
: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-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
: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-white dark:bg-[#3a3a3c] border border-black/8 dark:border-white/8 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.1)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.3)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-110 active:scale-100 transition-all duration-200"
:title="t('shareTemplate')">
<i class="fas fa-share-alt text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 网格布局 - Apple 风格 -->
<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`
}">
<!-- 列内的模版卡片 - Apple 风格 -->
<div v-for="item in column.templates" :key="item.task_id"
class="mb-3 group relative bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] rounded-2xl overflow-hidden border border-black/8 dark:border-white/8 hover:border-[color:var(--brand-primary)]/30 dark:hover:border-[color:var(--brand-primary-light)]/30 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 hover:shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.15)] dark:hover:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.2)]">
<!-- 视频缩略图区域 -->
<div class="cursor-pointer bg-black/2 dark:bg-white/2 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="item?.inputs?.input_image ? getTemplateFileUrl(item.inputs.input_image,'images') : undefined"
class="w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
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="item?.inputs?.input_image ? getTemplateFileUrl(item.inputs.input_image,'images') : undefined"
:alt="item.params?.prompt || '模板图片'"
class="w-full h-auto object-contain group-hover:scale-[1.02] transition-transform duration-200"
@error="handleThumbnailError" />
<!-- 移动端播放按钮 - Apple 风格 -->
<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-white/95 dark:bg-[#2c2c2e]/95 backdrop-blur-[20px] shadow-[0_2px_8px_rgba(0,0,0,0.2)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)] flex items-center justify-center text-[#1d1d1f] dark:text-[#f5f5f7] hover:scale-105 transition-all duration-200 z-20">
<i class="fas fa-play text-sm"></i>
</button>
<!-- 悬浮操作按钮(仅当 showActions 为 true 时显示)- Apple 风格 -->
<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 gap-2 pointer-events-auto">
<button @click.stop="applyTemplateImage(item)"
class="w-10 h-10 rounded-full bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
: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-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
: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-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] backdrop-blur-[20px] shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)] flex items-center justify-center text-white hover:scale-110 active:scale-100 transition-all duration-200"
: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 { onMounted } from 'vue'
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,
theme,
initTheme,
toggleTheme,
getThemeIcon,
switchToCreateView
} from '../utils/other'
// 初始化主题
onMounted(() => {
initTheme()
})
</script>
<template>
<!-- Apple 风格顶部栏 - Tailwind 深浅色 -->
<div class="sticky top-0 z-[100] bg-white/80 dark:bg-[#1e1e1e]/80 backdrop-blur-[20px] backdrop-saturate-[180%] border-b border-black/8 dark:border-white/8 shadow-[0_1px_3px_0_rgba(0,0,0,0.05)] dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.3)] transition-all duration-300 flex-shrink-0">
<div class="flex justify-between items-center max-w-full mx-auto px-6 py-3">
<!-- 左侧 Logo -->
<div class="flex items-center">
<button @click="switchToCreateView"
class="flex items-center gap-2.5 px-3 py-2 bg-transparent border-0 rounded-[10px] cursor-pointer transition-all duration-200 hover:bg-black/4 dark:hover:bg-white/6 hover:-translate-y-px active:scale-[0.97]"
:title="t('goToHome')">
<img src="../../public/logo.svg" alt="LightX2V" class="w-6 h-6 sm:w-6 sm:h-6 md:w-8 md:h-8 lg:w-8 lg:h-8" loading="lazy" />
<span class="inline-flex items-baseline text-[20px] font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-[-0.025em]">
<span>Light</span>
<span class="text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]">X2V</span>
</span>
</button>
</div>
<!-- 右侧用户信息和控制 -->
<div class="flex items-center gap-4">
<!-- 主题切换按钮 -->
<button @click="toggleTheme"
class="flex items-center justify-center w-9 h-9 p-0 bg-transparent border-0 rounded-lg cursor-pointer transition-all duration-200 hover:bg-black/4 dark:hover:bg-white/8 hover:scale-105 active:scale-95"
:title="'切换主题'">
<i :class="getThemeIcon()" class="text-base text-[#86868b] dark:text-[#98989d] transition-all duration-200"></i>
</button>
<!-- 语言切换按钮 - Apple 精致风格 -->
<button @click="switchLang"
class="relative flex items-center justify-center w-9 h-9 p-0 bg-black/2 dark:bg-white/4 border border-black/6 dark:border-white/8 rounded-full cursor-pointer transition-all duration-200 hover:bg-black/6 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/15 hover:scale-110 hover:shadow-[0_2px_8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_2px_8px_rgba(0,0,0,0.3)] active:scale-100"
:title="t('switchLanguage')">
<span class="text-base leading-none filter grayscale-0 hover:grayscale-0 transition-all">{{ languageOptions.find(lang => lang.code === (locale === 'zh' ? 'en' : 'zh'))?.flag }}</span>
</button>
<!-- 用户信息卡片 - Apple 精致风格 -->
<div class="flex items-center gap-2.5 px-3 py-1.5 bg-black/2 dark:bg-white/4 border border-black/6 dark:border-white/8 rounded-[20px] transition-all duration-200 hover:bg-black/4 dark:hover:bg-white/8 hover:border-black/8 dark:hover:border-white/12 hover:shadow-[0_2px_8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_2px_8px_rgba(0,0,0,0.3)]">
<!-- 用户头像 -->
<div class="flex items-center justify-center w-8 h-8 flex-shrink-0">
<img v-if="currentUser.avatar_url"
:src="currentUser.avatar_url"
:alt="currentUser.username"
class="w-full h-full rounded-full object-cover border border-black/8 dark:border-white/12 shadow-[0_1px_3px_rgba(0,0,0,0.1)] dark:shadow-[0_1px_3px_rgba(0,0,0,0.3)]">
<!-- 默认头像 - Apple 风格圆形图标 -->
<div v-else class="w-full h-full rounded-full bg-gradient-to-br from-[#86868b]/20 to-[#86868b]/10 dark:from-[#98989d]/20 dark:to-[#98989d]/10 border border-black/8 dark:border-white/12 flex items-center justify-center">
<i class="fas fa-user text-[14px] text-[#86868b] dark:text-[#98989d]"></i>
</div>
</div>
<!-- 用户名 -->
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-[-0.01em] whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]">
<span v-if="currentUser">
{{ currentUser.username || currentUser.email || '用户' }}
</span>
<span v-else>未登录</span>
</div>
<!-- 登录/登出按钮 - Apple 精致风格 -->
<button v-if="currentUser.username"
@click="logout"
class="flex items-center justify-center w-7 h-7 p-0 bg-transparent border-0 rounded-full cursor-pointer transition-all duration-200 hover:bg-red-500/10 dark:hover:bg-red-400/15 hover:scale-110 active:scale-100 flex-shrink-0 group"
:title="t('logout')">
<i class="fas fa-arrow-right-from-bracket text-[13px] text-[#86868b] dark:text-[#98989d] group-hover:text-red-500 dark:group-hover:text-red-400 transition-colors"></i>
</button>
<button v-else
@click="login"
class="flex items-center justify-center w-7 h-7 p-0 bg-transparent border-0 rounded-full cursor-pointer transition-all duration-200 hover:bg-[color:var(--brand-primary)]/10 dark:hover:bg-[color:var(--brand-primary-light)]/15 hover:scale-110 active:scale-100 flex-shrink-0 group"
:title="t('login')">
<i class="fas fa-arrow-right-to-bracket text-[13px] text-[#86868b] dark:text-[#98989d] group-hover:text-[color:var(--brand-primary)] dark:group-hover:text-[color:var(--brand-primary-light)] transition-colors"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 所有样式已通过 Tailwind CSS 的 dark: 前缀在 template 中定义 */
/* 不需要额外的 CSS 规则 */
</style>
<template>
<div class="voice-selector voice-selector-component w-full" :class="{ 'dropdown-mode': mode === 'dropdown' }">
<!-- 完整模式:包含搜索和筛选 -->
<template v-if="mode === 'full'">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<i class="fas fa-microphone-alt text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight">{{ t('selectVoice') }}</span>
<button
v-if="showHistoryButton"
@click="$emit('open-history')"
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200"
:title="t('ttsHistoryTabVoice')"
>
<i class="fas fa-history text-xs"></i>
</button>
</div>
</div>
<div v-if="showSearch || showFilter" class="flex items-center gap-3 mb-4">
<!-- 搜索框 - Apple 风格 -->
<div v-if="showSearch" class="relative w-52">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-[#86868b] dark:text-[#98989d] text-xs pointer-events-none z-10"></i>
<input
:value="searchQuery"
@input="$emit('update-search', $event.target.value)"
:placeholder="t('searchVoice')"
class="w-full bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-lg py-2 pl-9 pr-3 text-sm text-[#1d1d1f] dark:text-[#f5f5f7] placeholder-[#86868b] dark:placeholder-[#98989d] tracking-tight hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 focus:outline-none focus:border-[color:var(--brand-primary)]/50 dark:focus:border-[color:var(--brand-primary-light)]/60 transition-all duration-200"
type="text"
/>
</div>
<!-- 筛选按钮 - Apple 风格 -->
<button
v-if="showFilter"
@click="$emit('toggle-filter')"
class="flex items-center gap-2 px-4 py-2 bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-lg transition-all duration-200 text-sm font-medium tracking-tight"
>
<i class="fas fa-filter text-xs"></i>
<span>{{ t('filter') }}</span>
</button>
</div>
</template>
<!-- 音色列表容器 - Apple 风格 -->
<div
:class="{
'bg-white/50 dark:bg-[#2c2c2e]/50 backdrop-blur-[10px] border border-black/6 dark:border-white/6 rounded-2xl p-5 max-h-[500px] overflow-y-auto main-scrollbar pr-3': mode === 'full',
'p-3 max-h-96 overflow-y-auto main-scrollbar': mode === 'dropdown'
}"
ref="voiceListContainer"
>
<div :class="{ 'grid grid-cols-1 md:grid-cols-2 gap-3': mode === 'full', 'space-y-2': mode === 'dropdown' }">
<label
v-for="(voice, index) in filteredVoices"
:key="index"
:class="{
'relative m-0 p-0 cursor-pointer': mode === 'full',
'relative m-0 p-0 cursor-pointer': mode === 'dropdown'
}"
>
<input
type="radio"
:value="voice.voice_type"
:checked="selectedVoice === voice.voice_type"
@change="$emit('select-voice', voice)"
class="sr-only"
/>
<div
class="relative flex items-center bg-white/80 dark:bg-[#2c2c2e]/80 backdrop-blur-[20px] border border-black/8 dark:border-white/8 rounded-xl transition-all duration-200 hover:bg-white dark:hover:bg-[#3a3a3c] hover:border-black/12 dark:hover:border-white/12 hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)]"
:class="{
'border-2 border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] bg-[color:var(--brand-primary)]/12 dark:bg-[color:var(--brand-primary-light)]/20 shadow-[0_8px_24px_rgba(var(--brand-primary-rgb),0.25)] dark:shadow-[0_8px_24px_rgba(var(--brand-primary-light-rgb),0.35)] ring-2 ring-[color:var(--brand-primary)]/20 dark:ring-[color:var(--brand-primary-light)]/30': selectedVoice === voice.voice_type,
'p-4': mode === 'full',
'p-3': mode === 'dropdown'
}"
@click="mode === 'dropdown' && $emit('select-voice', voice)"
>
<!-- 选中指示器 - Apple 风格 -->
<div v-if="selectedVoice === voice.voice_type"
class="absolute w-5 h-5 bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] rounded-full flex items-center justify-center z-10 shadow-[0_2px_8px_rgba(var(--brand-primary-rgb),0.3)] dark:shadow-[0_2px_8px_rgba(var(--brand-primary-light-rgb),0.4)]"
:class="'top-2 left-2'"
>
<i class="fas fa-check text-white text-[10px]"></i>
</div>
<!-- V2 标签 - Apple 风格(在 dropdown 模式下选中时隐藏) -->
<div v-if="voice.version === '2.0'" class="absolute top-2 right-2 px-2 py-1 bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white text-[10px] font-semibold rounded-md z-10">
v2.0
</div>
<!-- 头像容器 -->
<div class="relative flex-shrink-0 mr-3"
>
<!-- Female Avatar -->
<img
v-if="isFemaleVoice(voice.voice_type)"
src="../../public/female.svg"
alt="Female Avatar"
:class="{
'w-12 h-12': mode === 'full',
'w-10 h-10': mode === 'dropdown'
}"
class="rounded-full object-cover bg-white transition-all duration-200"
/>
<!-- Male Avatar -->
<img
v-else
src="../../public/male.svg"
alt="Male Avatar"
:class="{
'w-12 h-12': mode === 'full',
'w-10 h-10': mode === 'dropdown'
}"
class="rounded-full object-cover bg-white transition-all duration-200"
/>
<!-- Loading 指示器 - Apple 风格 -->
<div v-if="isGenerating && selectedVoice === voice.voice_type" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-[color:var(--brand-primary)]/90 dark:bg-[color:var(--brand-primary-light)]/90 rounded-full flex items-center justify-center text-white z-20">
<i class="fas fa-spinner fa-spin text-xs"></i>
</div>
</div>
<!-- 音色信息 -->
<div class="flex-1 min-w-0">
<div class="font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate"
:class="{
'text-sm mb-1': mode === 'full' || (mode === 'dropdown' && selectedVoice !== voice.voice_type),
'text-xs': mode === 'dropdown' && selectedVoice === voice.voice_type
}"
>
{{ voice.name }}
</div>
<!-- 场景和语言标签 - 在 full 模式下或 dropdown 模式下未选中时显示 -->
<div
class="flex flex-wrap gap-1.5"
>
<span v-if="voice.scene" class="inline-block px-2 py-0.5 bg-black/5 dark:bg-white/5 text-[#86868b] dark:text-[#98989d] rounded text-[11px] font-medium">
{{ voice.scene }}
</span>
<span
v-for="langCode in voice.language"
:key="langCode"
class="inline-block px-2 py-0.5 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] rounded text-[11px] font-medium"
>
{{ getLanguageDisplayName(langCode) }}
</span>
</div>
</div>
</div>
</label>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// Props
const props = defineProps({
voices: {
type: Array,
default: () => []
},
filteredVoices: {
type: Array,
required: true
},
selectedVoice: {
type: String,
default: ''
},
searchQuery: {
type: String,
default: ''
},
isGenerating: {
type: Boolean,
default: false
},
mode: {
type: String,
default: 'full', // 'full' | 'dropdown'
validator: (value) => ['full', 'dropdown'].includes(value)
},
showSearch: {
type: Boolean,
default: true
},
showFilter: {
type: Boolean,
default: true
},
showHistoryButton: {
type: Boolean,
default: true
}
})
// Emits
const emit = defineEmits(['select-voice', 'update-search', 'toggle-filter', 'open-history'])
// Refs
const voiceListContainer = ref(null)
// 检查是否为女性音色
const isFemaleVoice = (name) => {
return name.toLowerCase().includes('female')
}
// 语言代码转显示名称
const getLanguageDisplayName = (langCode) => {
const languageMap = {
'chinese': '中文',
'en_us': '美式英语',
'en_gb': '英式英语',
'en_au': '澳洲英语',
'es': '西语',
'ja': '日语'
}
return languageMap[langCode] || langCode
}
// 暴露给父组件的方法
defineExpose({
voiceListContainer
})
</script>
<style scoped>
/* 所有样式已通过 Tailwind CSS 在 template 中定义 */
</style>
<script setup>
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
history: {
type: Array,
default: () => []
},
mode: {
type: String,
default: 'combined'
},
getVoiceName: {
type: Function,
default: () => ''
}
})
const emit = defineEmits(['close', 'apply', 'delete'])
const { t } = useI18n()
const normalizedMode = computed(() => {
const modes = ['combined', 'text', 'instruction', 'voice']
return modes.includes(props.mode) ? props.mode : 'combined'
})
const makeTextEntries = () => {
const seen = new Set()
const list = []
for (const entry of props.history || []) {
const value = (entry?.text || '').trim()
if (!value || seen.has(value)) continue
seen.add(value)
list.push({ id: value, value, label: value })
}
return list
}
const makeInstructionEntries = () => {
const seen = new Set()
const list = []
for (const entry of props.history || []) {
const value = (entry?.instruction || '').trim()
if (!value || seen.has(value)) continue
seen.add(value)
list.push({ id: value, value, label: value })
}
return list
}
const makeVoiceEntries = () => {
const seen = new Set()
const list = []
for (const entry of props.history || []) {
const value = (entry?.voiceType || '').trim()
const label = props.getVoiceName(entry) || entry?.voiceName || value
if (!value || seen.has(value)) continue
seen.add(value)
list.push({ id: value, value, label })
}
return list
}
const filteredHistory = computed(() => {
switch (normalizedMode.value) {
case 'text':
return makeTextEntries()
case 'instruction':
return makeInstructionEntries()
case 'voice':
return makeVoiceEntries()
case 'combined':
default:
return props.history || []
}
})
const totalCount = computed(() => filteredHistory.value.length)
const selectedKey = ref(null)
const panelTitle = computed(() => {
const map = {
combined: t('ttsHistoryTitleCombined'),
text: t('ttsHistoryTitleText'),
instruction: t('ttsHistoryTitleInstruction'),
voice: t('ttsHistoryTitleVoice')
}
return map[normalizedMode.value] || t('ttsHistoryTitle')
})
const isFemaleVoice = (entry) => {
const value = (entry?.voiceType || entry?.voiceName || entry?.label || '').toLowerCase()
return value.includes('female') || value.includes('')
}
const getEntryKey = (entry) => {
if (normalizedMode.value === 'combined') {
return entry?.id ?? null
}
return entry?.value ?? null
}
const ensureSelection = () => {
if (!props.visible) {
selectedKey.value = null
return
}
const list = filteredHistory.value
if (!list.length) {
selectedKey.value = null
return
}
const currentKey = selectedKey.value
if (list.some((entry) => getEntryKey(entry) === currentKey)) {
return
}
selectedKey.value = getEntryKey(list[0])
}
watch(() => props.visible, ensureSelection)
watch(filteredHistory, ensureSelection)
watch(normalizedMode, ensureSelection)
const isCombinedMode = computed(() => normalizedMode.value === 'combined')
const isApplyDisabled = computed(() => !props.visible || !selectedKey.value)
const handleOverlayClick = () => {
emit('close')
}
const handlePanelClick = (event) => {
event.stopPropagation()
}
const handleEntryClick = (entry) => {
selectedKey.value = getEntryKey(entry)
}
const handleApplyClick = () => {
if (isApplyDisabled.value) return
if (normalizedMode.value === 'combined') {
const entry = filteredHistory.value.find((item) => getEntryKey(item) === selectedKey.value)
if (entry) {
emit('apply', entry)
}
} else {
emit('apply', selectedKey.value)
}
}
const handleDeleteClick = (event, entry) => {
if (!isCombinedMode.value) return
event.stopPropagation()
emit('delete', entry)
}
const getEntryVoiceLabel = (entry) => {
return props.getVoiceName(entry) || entry.voiceType || t('ttsHistoryVoiceEmpty')
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
@click="handleOverlayClick"
>
<div
class="bg-white/95 dark:bg-[#1e1e1e]/95 backdrop-blur-[40px] backdrop-saturate-[180%] border border-black/10 dark:border-white/10 rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.6)] flex flex-col"
@click.stop="handlePanelClick"
>
<div class="flex items-center justify-between px-6 py-4 border-b border-black/8 dark:border-white/8 bg-white/50 dark:bg-[#1e1e1e]/50 backdrop-blur-[20px]">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight flex items-center gap-2">
<i class="fas fa-history text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)]"></i>
<span>{{ panelTitle }}</span>
</h3>
<span
v-if="totalCount > 0"
class="px-2 py-0.5 rounded-full text-xs font-medium bg-black/5 dark:bg-white/10 text-[#86868b] dark:text-[#98989d]"
>
{{ totalCount }}
</span>
</div>
<div class="flex items-center gap-2">
<button
@click.stop="handleApplyClick"
:disabled="isApplyDisabled"
class="w-9 h-9 flex items-center justify-center rounded-full transition-all duration-200 bg-[color:var(--brand-primary)] dark:bg-[color:var(--brand-primary-light)] text-white disabled:opacity-50 disabled:cursor-not-allowed hover:scale-105 active:scale-100"
:title="t('ttsHistoryApplySelected')"
>
<i class="fas fa-check text-sm"></i>
</button>
<button
@click.stop="emit('close')"
class="w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-[#86868b] dark:text-[#98989d] hover:text-[#1d1d1f] dark:hover:text-[#f5f5f7] hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200 hover:scale-110 active:scale-100"
>
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
<div class="flex-1 min-h-[50vh] overflow-y-auto p-6 main-scrollbar">
<div v-if="!filteredHistory.length" class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 bg-[color:var(--brand-primary)]/10 dark:bg-[color:var(--brand-primary-light)]/15 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-book text-[color:var(--brand-primary)] dark:text-[color:var(--brand-primary-light)] text-2xl"></i>
</div>
<p class="text-[#1d1d1f] dark:text-[#f5f5f7] text-lg font-medium mb-2 tracking-tight">{{ t('ttsHistoryEmpty') }}</p>
<p class="text-[#86868b] dark:text-[#98989d] text-sm tracking-tight">{{ t('ttsHistoryEmptyHint') }}</p>
</div>
<template v-else>
<div v-if="normalizedMode === 'voice'" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="entry in filteredHistory"
:key="getEntryKey(entry)"
@click="handleEntryClick(entry)"
class="p-4 border border-black/8 dark:border-white/8 rounded-2xl bg-white/80 dark:bg-[#2c2c2e]/80 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 cursor-pointer flex items-center gap-3"
:class="{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] shadow-[0_0_0_2px_rgba(var(--brand-primary-rgb),0.2)] dark:shadow-[0_0_0_2px_rgba(var(--brand-primary-light-rgb),0.25)] ring-2 ring-[color:var(--brand-primary)]/20 dark:ring-[color:var(--brand-primary-light)]/25': getEntryKey(entry) === selectedKey
}"
>
<div class="relative flex-shrink-0">
<img
v-if="isFemaleVoice(entry)"
src="../../public/female.svg"
alt="Female Avatar"
class="w-12 h-12 rounded-full object-cover bg-white transition-all duration-200"
/>
<!-- Male Avatar -->
<img
v-else
src="../../public/male.svg"
alt="Male Avatar"
class="w-12 h-12 rounded-full object-cover bg-white transition-all duration-200"
/>
</div>
<div class="flex-1 min-w-0 space-y-1">
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight truncate">
{{ entry.label }}
</div>
<div class="text-xs text-[#86868b] dark:text-[#98989d] tracking-tight truncate">
{{ entry.voiceType }}
</div>
</div>
</div>
</div>
<div v-else class="space-y-3">
<div
v-for="entry in filteredHistory"
:key="getEntryKey(entry)"
@click="handleEntryClick(entry)"
class="p-4 border border-black/8 dark:border-white/8 rounded-2xl bg-white/80 dark:bg-[#2c2c2e]/80 hover:bg-white dark:hover:bg-[#3a3a3c] transition-all duration-200 cursor-pointer"
:class="{
'border-[color:var(--brand-primary)] dark:border-[color:var(--brand-primary-light)] shadow-[0_0_0_2px_rgba(var(--brand-primary-rgb),0.2)] dark:shadow-[0_0_0_2px_rgba(var(--brand-primary-light-rgb),0.25)]': getEntryKey(entry) === selectedKey
}"
>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0 space-y-2">
<template v-if="normalizedMode === 'combined'">
<div class="text-sm font-semibold text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words whitespace-pre-line">
<span class="text-xs uppercase text-[#86868b] dark:text-[#98989d] mr-2">{{ t('ttsHistoryTextLabel') }}:</span>
<span>{{ entry.text || t('ttsHistoryTextEmpty') }}</span>
</div>
<div class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words whitespace-pre-line">
<span class="text-xs uppercase text-[#86868b] dark:text-[#98989d] mr-2">{{ t('ttsHistoryInstructionLabel') }}:</span>
<span>{{ entry.instruction || t('ttsHistoryInstructionEmpty') }}</span>
</div>
<div class="text-sm text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words">
<span class="text-xs uppercase text-[#86868b] dark:text-[#98989d] mr-2">{{ t('ttsHistoryVoiceLabel') }}:</span>
<span>{{ getEntryVoiceLabel(entry) }}</span>
</div>
</template>
<template v-else>
<div class="text-sm font-medium text-[#1d1d1f] dark:text-[#f5f5f7] tracking-tight break-words whitespace-pre-line">
<span>{{ entry.label }}</span>
</div>
</template>
</div>
<button
v-if="isCombinedMode"
@click="handleDeleteClick($event, entry)"
class="w-9 h-9 flex items-center justify-center bg-white/80 dark:bg-[#2c2c2e]/80 border border-black/8 dark:border-white/8 text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-white dark:hover:bg-[#3a3a3c] rounded-full transition-all duration-200"
:title="t('ttsHistoryDeleteEntry')"
>
<i class="fas fa-trash text-sm"></i>
</button>
</div>
</div>
</div>
</div>
</template>
</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