Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
Menu
Open sidebar
OpenDAS
LightX2V
Commits
a1ebc651
Commit
a1ebc651
authored
Dec 11, 2025
by
xuwx1
Browse files
updata lightx2v
parent
5a4db490
Pipeline
#3149
canceled with stages
Changes
428
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
2965 additions
and
0 deletions
+2965
-0
lightx2v/deploy/server/frontend/src/components/SiteFooter.vue
...tx2v/deploy/server/frontend/src/components/SiteFooter.vue
+38
-0
lightx2v/deploy/server/frontend/src/components/TaskCarousel.vue
...2v/deploy/server/frontend/src/components/TaskCarousel.vue
+497
-0
lightx2v/deploy/server/frontend/src/components/TaskDetails.vue
...x2v/deploy/server/frontend/src/components/TaskDetails.vue
+1154
-0
lightx2v/deploy/server/frontend/src/components/TemplateDetails.vue
...deploy/server/frontend/src/components/TemplateDetails.vue
+335
-0
lightx2v/deploy/server/frontend/src/components/TemplateDisplay.vue
...deploy/server/frontend/src/components/TemplateDisplay.vue
+294
-0
lightx2v/deploy/server/frontend/src/components/TopBar.vue
lightx2v/deploy/server/frontend/src/components/TopBar.vue
+106
-0
lightx2v/deploy/server/frontend/src/components/VoiceSelector.vue
...v/deploy/server/frontend/src/components/VoiceSelector.vue
+233
-0
lightx2v/deploy/server/frontend/src/components/VoiceTtsHistoryPanel.vue
...y/server/frontend/src/components/VoiceTtsHistoryPanel.vue
+308
-0
No files found.
Too many changes to show.
To preserve performance only
428 of 428+
files are displayed.
Plain diff
Email patch
lightx2v/deploy/server/frontend/src/components/SiteFooter.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/TaskCarousel.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/TaskDetails.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/TemplateDetails.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/TemplateDisplay.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/TopBar.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/VoiceSelector.vue
0 → 100644
View file @
a1ebc651
<
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
>
lightx2v/deploy/server/frontend/src/components/VoiceTtsHistoryPanel.vue
0 → 100644
View file @
a1ebc651
<
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>
Prev
1
…
18
19
20
21
22
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment