refactor!: 升级 @nuxt/ui@3,重构所有页面和组件,调整配置,移除不在需求中的页面

This commit is contained in:
2026-02-10 18:07:44 +08:00
parent d0bca215c1
commit 75f1987be3
49 changed files with 4892 additions and 6599 deletions

View File

@@ -6,7 +6,6 @@ import gsap from 'gsap'
const toast = useToast()
const loginState = useLoginState()
const { metaSymbol } = useShortcuts()
const srtEditor = ref()
@@ -59,17 +58,17 @@ const isPreviewModalOpen = ref(false)
const stateDisplay = computed(() => {
if (props.course.progress === -1)
return {
color: 'red',
color: 'error' as const,
text: '失败',
}
if (props.course.progress === 100)
return {
color: 'green',
color: 'success' as const,
text: '完成',
}
return {
color: 'blue',
text: !!props.course.progress
color: 'info' as const,
text: props.course.progress
? `${tweenedGenerateProgress.value.toFixed(0)}%`
: '队列中',
}
@@ -108,7 +107,7 @@ const startDownload = async (url: string, filename: string) => {
toast.add({
title: '下载完成',
description: '资源下载已完成',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
})
@@ -122,7 +121,7 @@ const startDownload = async (url: string, filename: string) => {
toast.add({
title: '下载失败',
description: err.message || '下载失败,未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
})
@@ -137,7 +136,7 @@ const copyTaskId = (extraMessage?: string) => {
toast.add({
title: '复制成功',
description: '已复制任务 ID',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
}
@@ -155,7 +154,7 @@ const onCombination = async () => {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -174,7 +173,7 @@ const onCombination = async () => {
toast.add({
title: '嵌入字幕失败',
description: err.message || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
combinationState.value = 0
@@ -208,7 +207,7 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
toast.add({
title: '重试已提交',
description: '已加入生成队列',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
// delete
@@ -217,7 +216,7 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
toast.add({
title: '提交重试失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
}
@@ -226,7 +225,7 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
toast.add({
title: '提交重试失败',
description: err.message || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
}
@@ -236,11 +235,11 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
<template>
<div
class="w-full rounded-xl border border-neutral-200 dark:border-neutral-700 hover:shadow-sm transition overflow-hidden"
class="hover:shadow-xs w-full overflow-hidden rounded-xl border border-neutral-200 transition dark:border-neutral-700"
>
<div class="relative w-full aspect-video group">
<div class="group relative aspect-video w-full">
<NuxtImg
class="w-full aspect-video object-cover pointer-events-none absolute inset-0"
class="pointer-events-none absolute inset-0 aspect-video w-full object-cover"
v-if="!!course.video_cover"
:src="course.video_cover"
alt="image"
@@ -248,33 +247,33 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
/>
<div
v-else
class="absolute inset-0 bg-linear-to-br from-purple-400 to-primary-400 flex justify-center items-center pattern"
class="bg-linear-to-br to-primary-400 pattern absolute inset-0 flex items-center justify-center from-purple-400"
>
<Icon
v-if="isFailed"
class="text-white text-[64px] opacity-50"
class="text-[64px] text-white opacity-50"
name="i-tabler-alert-triangle"
/>
<Icon
v-else
class="text-white text-[64px] animate-pulse"
class="animate-pulse text-[64px] text-white"
name="i-tabler-photo-video"
/>
</div>
<div class="absolute inset-2 flex justify-end items-start">
<div class="absolute inset-2 flex items-start justify-end">
<UTooltip
:prevent="course.progress > -1"
:text="course.message || ''"
>
<UBadge
:color="stateDisplay.color"
:variant="isFailed ? 'solid' : 'subtle'"
class="shadow-sm"
variant="solid"
class="shadow-xs"
size="sm"
>
<Icon
v-if="isFailed"
class="text-base mr-0.5"
class="mr-0.5 text-base"
name="i-tabler-alert-triangle"
/>
{{ stateDisplay.text }}
@@ -283,24 +282,24 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
</div>
<div
v-if="isDownloadable"
class="absolute inset-0 bg-black/10 backdrop-blur-md flex justify-center items-center opacity-0 group-hover:opacity-100 duration-300"
class="absolute inset-0 flex items-center justify-center bg-black/10 opacity-0 backdrop-blur-md duration-300 group-hover:opacity-100"
>
<div
class="rounded-full w-14 aspect-square bg-gray-300/50 backdrop-blur-md flex justify-center items-center cursor-pointer"
class="flex aspect-square w-14 cursor-pointer items-center justify-center rounded-full bg-gray-300/50 backdrop-blur-md"
@click="isPreviewModalOpen = true"
>
<Icon
name="i-tabler-play"
class="text-white text-3xl"
class="text-3xl text-white"
/>
</div>
</div>
</div>
<div class="px-2 pt-1 pb-2 flex justify-between">
<div class="flex justify-between px-2 pb-2 pt-1">
<div class="flex-1 overflow-hidden pt-1">
<h1
:title="course.title"
class="inline-flex items-center text-sm font-medium overflow-hidden text-ellipsis text-nowrap"
class="inline-flex items-center overflow-hidden text-ellipsis text-nowrap text-sm font-medium"
>
<Icon
class="text-base"
@@ -308,7 +307,7 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
/>
<span class="pl-0.5">{{ course.title }}</span>
</h1>
<p class="text-xs pt-0.5 text-neutral-400 space-x-2">
<p class="space-x-2 pt-0.5 text-xs text-neutral-400">
<span>
{{ dayjs(course.create_time * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</span>
@@ -327,32 +326,15 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
</button>
</p>
</div>
<div class="flex items-center gap-1">
<div class="flex items-end gap-1">
<UButtonGroup>
<!-- <UButton
v-if="isFailed"
color="white"
:disabled="!isFailed"
label="重试"
leading-icon="i-tabler-refresh"
size="xs"
@click="onRetryClick(course)"
/>
<UButton
v-else
color="white"
:disabled="!isDownloadable"
label="下载"
leading-icon="i-tabler-download"
size="xs"
@click="onCombination"
/> -->
<UButton
color="white"
color="neutral"
variant="outline"
:disabled="!isFailed && !isDownloadable"
:label="isFailed ? '重试' : isDownloadable ? '下载' : '生成中'"
:leading-icon="isFailed ? 'i-tabler-refresh' : 'i-tabler-download'"
size="xs"
size="sm"
@click="
() => {
if (isFailed) {
@@ -364,15 +346,18 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
"
/>
<!-- retry -->
<UDropdown
<UDropdownMenu
v-model:open="isDropdownOpen"
:content="{
align: 'end',
}"
:items="[
[
{
label: '下载原视频',
icon: 'i-tabler-file-plus',
disabled: !isDownloadable,
click: () =>
onClick: () =>
startDownload(
course.video_url,
`眩生花微课_${props.course.title}_${props.course.task_id}.mp4`
@@ -383,14 +368,14 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
icon: 'i-tabler-play',
shortcuts: ['P'],
disabled: !isDownloadable,
click: () => (isPreviewModalOpen = true),
onClick: () => (isPreviewModalOpen = true),
},
{
label: '编辑字幕',
icon: 'i-solar-subtitles-linear',
shortcuts: [metaSymbol, 'D'],
shortcuts: ['meta', 'D'],
disabled: !isDownloadable,
click: () => {
onClick: () => {
srtEditor.open()
isDropdownOpen = false
},
@@ -398,9 +383,9 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
{
label: '下载字幕',
icon: 'i-tabler-file-download',
shortcuts: [metaSymbol, 'S'],
shortcuts: ['meta', 'S'],
disabled: !isDownloadable,
click: async () => {
onClick: async () => {
await startDownload(
await fetchCourseSubtitleUrl(course),
`眩生花微课_${props.course.title}_${props.course.task_id}.srt`
@@ -413,110 +398,104 @@ const onRetryClick = (course: resp.gen.CourseGenItem) => {
label: '删除记录',
icon: 'i-tabler-trash-x',
shortcuts: ['Delete'],
click: () => emit('delete', course.task_id),
onClick: () => emit('delete', course.task_id),
},
],
]"
:popper="{ placement: 'bottom-end' }"
>
<UButton
:disabled="course.progress > 1 && course.progress < 100"
color="white"
size="xs"
color="neutral"
variant="outline"
size="sm"
trailing-icon="i-tabler-dots"
/>
</UDropdown>
</UDropdownMenu>
</UButtonGroup>
</div>
</div>
<UModal
v-model="isPreviewModalOpen"
:ui="{ width: 'w-full sm:max-w-4xl' }"
>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>微课视频预览</p>
<p
class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis"
>
{{ course.title }}
</p>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
<video
class="w-full rounded-sm shadow-sm"
controls
autoplay
:src="course.video_url"
/>
</UCard>
<UModal v-model:open="isPreviewModalOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
<p>微课视频预览</p>
<p
class="w-full overflow-hidden text-ellipsis text-nowrap text-xs text-blue-500"
>
{{ course.title }}
</p>
</div>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
<video
class="rounded-xs shadow-xs w-full"
controls
autoplay
:src="course.video_url"
/>
</UCard>
</template>
</UModal>
<UModal v-model="isCombinationModalOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>嵌入视频字幕</p>
<p
class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis"
<UModal v-model:open="isCombinationModalOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{ course.title }}
</p>
<p>嵌入视频字幕</p>
<p
class="w-full overflow-hidden text-ellipsis text-nowrap text-xs text-blue-500"
>
{{ course.title }}
</p>
</div>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="isCombinationModalOpen = false"
/>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isCombinationModalOpen = false"
/>
</div>
</template>
<UProgress
animation="carousel"
:value="combinationState"
:max="['嵌入字幕中', '合并完成,开始下载']"
>
<template #step-0="{ step }">
<span class="inline-flex items-center gap-1 text-emerald-500">
<UIcon name="tabler:text-caption" />
{{ step }}
</span>
</template>
<template #step-1="{ step }">
<span class="inline-flex items-center gap-1 text-primary-500">
<UIcon name="tabler:paperclip" />
{{ step }}
</span>
</template>
</UProgress>
</UCard>
<UProgress
animation="carousel"
:value="combinationState"
:max="['嵌入字幕中', '合并完成,开始下载']"
>
<template #step-0="{ step }">
<span class="inline-flex items-center gap-1 text-emerald-500">
<UIcon name="tabler:text-caption" />
{{ step }}
</span>
</template>
<template #step-1="{ step }">
<span class="text-primary-500 inline-flex items-center gap-1">
<UIcon name="tabler:paperclip" />
{{ step }}
</span>
</template>
</UProgress>
</UCard>
</template>
</UModal>
<AigcGenerationSRTEditor
ref="srtEditor"

View File

@@ -61,7 +61,7 @@ const handleBackgroundFileSelect = (event: Event) => {
toast.add({
title: '文件类型错误',
description: '请选择一个图片文件',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -82,7 +82,7 @@ const composeBackgroundVideo = async () => {
toast.add({
title: '未选择图片',
description: '请先选择一个背景图片',
color: 'orange',
color: 'warning',
icon: 'i-tabler-alert-circle',
})
return
@@ -110,7 +110,7 @@ const composeBackgroundVideo = async () => {
toast.add({
title: '合成成功',
description: '背景已成功合成,可预览或下载',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
} catch (err: any) {
@@ -118,7 +118,7 @@ const composeBackgroundVideo = async () => {
toast.add({
title: '合成失败',
description: combinatorError.value,
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
} finally {
@@ -172,7 +172,7 @@ const startDownload = (url: string, filename: string) => {
toast.add({
title: '下载完成',
description: '资源下载已完成',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
})
@@ -186,7 +186,7 @@ const startDownload = (url: string, filename: string) => {
toast.add({
title: '下载失败',
description: err.message || '下载失败,未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
})
@@ -197,14 +197,14 @@ const startDownload = (url: string, filename: string) => {
<template>
<div
class="w-full flex gap-2 rounded-xl border border-neutral-200 dark:border-neutral-700 hover:shadow-sm transition overflow-hidden p-3"
class="hover:shadow-xs flex w-full gap-2 overflow-hidden rounded-xl border border-neutral-200 p-3 transition dark:border-neutral-700"
>
<div
class="flex-0 h-48 aspect-10/16 flex flex-col items-center justify-center rounded-lg shadow-sm overflow-hidden relative group"
class="aspect-10/16 shadow-xs group relative flex h-48 flex-col items-center justify-center overflow-hidden rounded-lg"
>
<div
v-if="!video.video_cover"
class="w-full h-full flex flex-col justify-center items-center gap-2"
class="flex h-full w-full flex-col items-center justify-center gap-2"
:class="!isFailed ? 'bg-primary' : 'bg-rose-400'"
>
<UIcon
@@ -232,36 +232,36 @@ const startDownload = (url: string, filename: string) => {
<NuxtImg
v-else
:src="video.video_cover"
class="w-full h-full brightness-90 object-cover"
class="h-full w-full object-cover brightness-90"
/>
<div
class="absolute inset-0 bg-black/10 backdrop-blur-md flex justify-center items-center rounded-lg opacity-0 group-hover:opacity-100 duration-300"
class="absolute inset-0 flex items-center justify-center rounded-lg bg-black/10 opacity-0 backdrop-blur-md duration-300 group-hover:opacity-100"
>
<div
class="rounded-full w-14 aspect-square bg-gray-300/50 backdrop-blur-md flex justify-center items-center cursor-pointer"
class="flex aspect-square w-14 cursor-pointer items-center justify-center rounded-full bg-gray-300/50 backdrop-blur-md"
@click="isPreviewModalOpen = true"
>
<Icon
name="i-tabler-play"
class="text-white text-3xl"
class="text-3xl text-white"
/>
</div>
</div>
</div>
<div class="flex-1 flex flex-col justify-between gap-2">
<div class="flex flex-1 flex-col justify-between gap-2">
<div
class="flex-1 rounded-lg bg-neutral-100 dark:bg-neutral-800 p-2 px-2.5"
class="flex-1 rounded-lg bg-neutral-100 p-2 px-2.5 dark:bg-neutral-800"
>
<ul class="grid grid-cols-2 gap-1.5">
<li class="col-span-2">
<!-- <h2 class="text-2xs font-medium text-primary-500">标题</h2>-->
<p class="text-sm font-bold line-clamp-1">
<p class="line-clamp-1 text-sm font-bold">
{{ video.title || '无标题' }}
</p>
</li>
<li class="">
<h2 class="text-2xs font-medium text-primary-500">完成时间</h2>
<p class="text-xs line-clamp-1">
<h2 class="text-primary-500 text-2xs font-medium">完成时间</h2>
<p class="line-clamp-1 text-xs">
{{
video.complete_time
? dayjs(video.complete_time * 1000).format(
@@ -272,8 +272,8 @@ const startDownload = (url: string, filename: string) => {
</p>
</li>
<li class="">
<h2 class="text-2xs font-medium text-primary-500">生成耗时</h2>
<p class="text-xs line-clamp-1">
<h2 class="text-primary-500 text-2xs font-medium">生成耗时</h2>
<p class="line-clamp-1 text-xs">
{{
video.duration
? dayjs.duration(video.duration || 0).format('HH:mm:ss')
@@ -285,13 +285,13 @@ const startDownload = (url: string, filename: string) => {
class="col-span-2 cursor-pointer"
@click="isFullContentOpen = true"
>
<h2 class="text-2xs font-medium text-primary-500">驱动文本</h2>
<p class="text-xs line-clamp-4 text-justify">{{ video.content }}</p>
<h2 class="text-primary-500 text-2xs font-medium">驱动文本</h2>
<p class="line-clamp-4 text-justify text-xs">{{ video.content }}</p>
</li>
</ul>
</div>
<div
class="flex justify-end sm:justify-between items-center group flex-nowrap whitespace-nowrap"
class="group flex flex-nowrap items-center justify-end whitespace-nowrap sm:justify-between"
>
<!-- <div-->
<!-- class="hidden sm:flex items-center gap-1 transition-all group-hover:opacity-0 group-hover:pointer-events-none">-->
@@ -299,7 +299,7 @@ const startDownload = (url: string, filename: string) => {
<!-- <p class="text-xs">数字人 {{ video.digital_human_id }}</p>-->
<!-- </div>-->
<div
class="w-fit hidden sm:flex items-center gap-1 transition-all group-hover:opacity-0 group-hover:pointer-events-none"
class="hidden w-fit items-center gap-1 transition-all group-hover:pointer-events-none group-hover:opacity-0 sm:flex"
>
<p class="text-2xs text-neutral-400 dark:text-neutral-500">
{{ video.digital_human_id }}
@@ -307,8 +307,8 @@ const startDownload = (url: string, filename: string) => {
</div>
<div class="space-x-2">
<UButton
class="transition-all sm:opacity-0 sm:translate-x-4 sm:pointer-events-none group-hover:opacity-100 group-hover:translate-x-0 group-hover:pointer-events-auto"
color="red"
class="transition-all group-hover:pointer-events-auto group-hover:translate-x-0 group-hover:opacity-100 sm:pointer-events-none sm:translate-x-4 sm:opacity-0"
color="error"
icon="i-tabler-trash"
size="xs"
variant="soft"
@@ -335,7 +335,7 @@ const startDownload = (url: string, filename: string) => {
)
"
/>
<UDropdown
<UDropdownMenu
:items="[
[
{
@@ -373,133 +373,125 @@ const startDownload = (url: string, filename: string) => {
leading-icon="i-tabler-download"
variant="soft"
/>
</UDropdown>
</UDropdownMenu>
</UButtonGroup>
</div>
</div>
</div>
<!-- Full video content -->
<UModal v-model="isFullContentOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{ video.title || '无标题' }}
<span class="block text-xs text-primary">驱动内容</span>
</h3>
<UButton
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isFullContentOpen = false"
/>
</div>
</template>
<div>
<article class="prose">
<p class="text-justify">{{ video.content }}</p>
</article>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="primary"
@click="isFullContentOpen = false"
>
关闭
</UButton>
</div>
</template>
</UCard>
</UModal>
<UModal v-model="isPreviewModalOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>绿幕视频预览</p>
<p
class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis"
<UModal v-model:open="isFullContentOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{ video.title }}
</p>
{{ video.title || '无标题' }}
<span class="text-primary block text-xs">驱动内容</span>
</h3>
<UButton
color="neutral"
variant="ghost"
icon="i-tabler-x"
@click="isFullContentOpen = false"
/>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
</template>
<video
class="w-full rounded-sm shadow-sm"
controls
autoplay
:src="video.video_url"
/>
</UCard>
</UModal>
<UModal v-model="isVideoBackgroundPreviewOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>视频背景合成</p>
<p
class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis"
<div>
<article class="prose">
<p class="text-justify">{{ video.content }}</p>
</article>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="primary"
@click="isFullContentOpen = false"
>
{{ video.title }}
</p>
关闭
</UButton>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isVideoBackgroundPreviewOpen = false"
/>
</div>
</template>
</template>
</UCard>
</template>
</UModal>
<div class="space-y-4">
<!-- 背景图片选择区域 -->
<div
v-if="!compositedVideoBlob && !isCombinatorLoading"
class="border-2 border-dashed border-neutral-200 dark:border-neutral-700 rounded-lg p-4"
>
<div class="space-y-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
选择背景图片
<UModal v-model:open="isPreviewModalOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
<p>绿幕视频预览</p>
<p
class="w-full overflow-hidden text-ellipsis text-nowrap text-xs text-blue-500"
>
{{ video.title }}
</p>
</div>
<UButton
class="-my-1"
color="neutral"
variant="ghost"
icon="i-tabler-x"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
<!-- 预览区域 -->
<!-- <div v-if="selectedBackgroundPreview" class="relative w-full aspect-video rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800">
<video
class="rounded-xs shadow-xs w-full"
controls
autoplay
:src="video.video_url"
/>
</UCard>
</template>
</UModal>
<UModal v-model:open="isVideoBackgroundPreviewOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
<p>视频背景合成</p>
<p
class="w-full overflow-hidden text-ellipsis text-nowrap text-xs text-blue-500"
>
{{ video.title }}
</p>
</div>
<UButton
class="-my-1"
color="neutral"
variant="ghost"
icon="i-tabler-x"
@click="isVideoBackgroundPreviewOpen = false"
/>
</div>
</template>
<div class="space-y-4">
<!-- 背景图片选择区域 -->
<div
v-if="!compositedVideoBlob && !isCombinatorLoading"
class="rounded-lg border-2 border-dashed border-neutral-200 p-4 dark:border-neutral-700"
>
<div class="space-y-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
选择背景图片
</div>
<!-- 预览区域 -->
<!-- <div v-if="selectedBackgroundPreview" class="relative w-full aspect-video rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800">
<img :src="selectedBackgroundPreview" alt="背景预览" class="w-full h-full object-cover" />
</div>
<div v-else class="w-full aspect-video rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 flex flex-col items-center justify-center gap-2">
@@ -507,119 +499,122 @@ const startDownload = (url: string, filename: string) => {
<span class="text-xs text-neutral-400">点击选择图片</span>
</div> -->
<!-- 文件输入 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleBackgroundFileSelect"
/>
<!-- 文件输入 -->
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleBackgroundFileSelect"
/>
<!-- 选择按钮 -->
<UButton
block
color="primary"
icon="i-tabler-photo-plus"
label="选择图片"
variant="soft"
@click="fileInputRef?.click()"
/>
<!-- 选择按钮 -->
<UButton
block
color="primary"
icon="i-tabler-photo-plus"
label="选择图片"
variant="soft"
@click="fileInputRef?.click()"
/>
<!-- 选中的文件名 -->
<div
v-if="selectedBackgroundFile"
class="text-xs text-neutral-500 dark:text-neutral-400"
>
已选择: {{ selectedBackgroundFile.name }}
<!-- 选中的文件名 -->
<div
v-if="selectedBackgroundFile"
class="text-xs text-neutral-500 dark:text-neutral-400"
>
已选择: {{ selectedBackgroundFile.name }}
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<UAlert
v-if="combinatorError"
color="red"
icon="i-tabler-alert-triangle"
title="合成失败"
:description="combinatorError"
/>
<!-- 错误提示 -->
<UAlert
v-if="combinatorError"
color="error"
icon="i-tabler-alert-triangle"
title="合成失败"
:description="combinatorError"
/>
<!-- 合成进度 -->
<div
v-if="isCombinatorLoading"
class="space-y-2"
>
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ phaseText }}
</span>
<span class="text-xs text-neutral-500">
{{ compositingProgress }}%
</span>
<!-- 合成进度 -->
<div
v-if="isCombinatorLoading"
class="space-y-2"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ phaseText }}
</span>
<span class="text-xs text-neutral-500">
{{ compositingProgress }}%
</span>
</div>
<UProgress :value="compositingProgress" />
</div>
<UProgress :value="compositingProgress" />
</div>
<!-- 合成预览 -->
<div
v-if="compositedVideoBlob"
class="space-y-2"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
视频预览
<!-- 合成预览 -->
<div
v-if="compositedVideoBlob"
class="space-y-2"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
视频预览
</div>
<video
class="shadow-xs w-full rounded-lg bg-black"
controls
autoplay
muted
:src="compositedVideoUrl"
/>
</div>
<video
class="w-full rounded-lg shadow-sm bg-black"
controls
autoplay
muted
:src="compositedVideoUrl"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="gray"
label="取消"
:disabled="isCombinatorLoading"
@click="isVideoBackgroundPreviewOpen = false"
/>
<UButton
v-if="compositedVideoBlob"
color="gray"
label="重新选择"
@click="
() => {
selectedBackgroundFile = null
selectedBackgroundPreview = ''
compositedVideoBlob = null
combinatorError = ''
isCombinatorLoading = false
}
"
/>
<UButton
v-if="compositedVideoBlob"
color="green"
icon="i-tabler-download"
label="下载合成视频"
@click="downloadCompositedVideo"
/>
<UButton
v-else
:disabled="!selectedBackgroundFile || isCombinatorLoading"
:loading="isCombinatorLoading"
color="primary"
icon="i-tabler-wand"
:label="isCombinatorLoading ? '合成中' : '开始合成'"
@click="composeBackgroundVideo"
/>
</div>
</template>
</UCard>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
label="取消"
:disabled="isCombinatorLoading"
@click="isVideoBackgroundPreviewOpen = false"
/>
<UButton
v-if="compositedVideoBlob"
color="neutral"
variant="outline"
label="重新选择"
@click="
() => {
selectedBackgroundFile = null
selectedBackgroundPreview = ''
compositedVideoBlob = null
combinatorError = ''
isCombinatorLoading = false
}
"
/>
<UButton
v-if="compositedVideoBlob"
color="success"
icon="i-tabler-download"
label="下载合成视频"
@click="downloadCompositedVideo"
/>
<UButton
v-else
:disabled="!selectedBackgroundFile || isCombinatorLoading"
:loading="isCombinatorLoading"
color="primary"
icon="i-tabler-wand"
:label="isCombinatorLoading ? '合成中' : '开始合成'"
@click="composeBackgroundVideo"
/>
</div>
</template>
</UCard>
</template>
</UModal>
</div>
</template>

View File

@@ -41,7 +41,7 @@ type subtitleStyleSchema = InferType<typeof subtitleStyleSchema>
const subtitleStyleState = reactive<subtitleStyleSchema>({
color: '#fff',
effect: 'shadow-sm',
effect: 'shadow-xs',
fontSize: 24,
bottomOffset: 12,
})
@@ -58,7 +58,7 @@ const loadSrt = async () => {
toast.add({
title: '加载字幕失败',
description: `${err}` || '未知错误',
color: 'red',
color: 'error',
})
} finally {
isLoading.value = false
@@ -79,8 +79,8 @@ const parseSrt = (srt: string) => {
subtitles.value.push(subtitle)
}
subtitle = {
start: match[1],
end: match[2],
start: match[1] || '',
end: match[2] || '',
text: '',
}
} else if (subtitle) {
@@ -105,23 +105,23 @@ const generateSrt = () => {
const formatTime = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0].split(':')
const timeParts = parts[0]?.split(':') || []
return {
hours: parseInt(timeParts[0]),
minutes: parseInt(timeParts[1]),
seconds: parseInt(timeParts[2]),
milliseconds: parseInt(parts[1]),
hours: parseInt(timeParts[0] || '0'),
minutes: parseInt(timeParts[1] || '0'),
seconds: parseInt(timeParts[2] || '0'),
milliseconds: parseInt(parts[1] || '0'),
}
}
const formatTimeToDayjs = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0].split(':')
const timeParts = parts[0]?.split(':') || []
return dayjs()
.hour(parseInt(timeParts[0]))
.minute(parseInt(timeParts[1]))
.second(parseInt(timeParts[2]))
.millisecond(parseInt(parts[1]))
.hour(parseInt(timeParts[0] || '0'))
.minute(parseInt(timeParts[1] || '0'))
.second(parseInt(timeParts[2] || '0'))
.millisecond(parseInt(parts[1] || '0'))
}
const syncSubtitles = () => {
@@ -183,7 +183,7 @@ const saveNewSubtitle = () => {
.then((_) => {
modified.value = false
toast.add({
color: 'green',
color: 'success',
title: '字幕已保存',
description: '修改后的字幕文件已保存',
})
@@ -202,7 +202,7 @@ const exportVideo = async () => {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -213,7 +213,7 @@ const exportVideo = async () => {
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize,
textShadow:
subtitleStyleState.effect === 'shadow-sm'
subtitleStyleState.effect === 'shadow-xs'
? {
offsetX: 2,
offsetY: 2,
@@ -258,346 +258,349 @@ defineExpose({
<template>
<div>
<USlideover
v-model="isDrawerActive"
:prevent-close="modified"
:ui="{ width: 'max-w-lg' }"
v-model:open="isDrawerActive"
:dismissible="!modified"
:ui="{
wrapper: 'max-w-lg',
body: 'flex flex-col flex-1 overflow-hidden',
}"
>
<UCard
class="flex flex-col flex-1 overflow-hidden"
:ui="{
body: { base: 'overflow-auto flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<UButton
color="gray"
variant="ghost"
size="sm"
icon="tabler:x"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isDrawerActive = false"
/>
<div class="flex flex-col">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
字幕编辑器
</h3>
<h3
class="text-xs font-semibold text-blue-500"
v-if="course.title"
>
{{ course.title }}
</h3>
</div>
</template>
<div
v-if="isLoading"
class="flex justify-center items-center text-primary"
<template #content>
<UCard
class="flex flex-1 flex-col overflow-hidden"
:ui="{
body: 'overflow-auto flex-1',
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur
in="SourceGraphic"
result="y"
stdDeviation="1"
/>
<feColorMatrix
in="y"
result="z"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"
/>
<feBlend
in="SourceGraphic"
in2="z"
/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle
cx="5"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="5;8;5"
/>
</circle>
<circle
cx="19"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="19;16;19"
/>
</circle>
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"
/>
</g>
</svg>
</div>
<div
v-else
class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow"
>
<div class="relative w-full aspect-video flex-1">
<div
class="absolute w-fit mx-auto inset-x-0 font-sans font-bold subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-sm'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
{{ subtitles.find((sub) => sub.active)?.text }}
</div>
<video
controls
ref="videoElement"
class="rounded-sm"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
<template #header>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="tabler:x"
class="absolute end-5 top-5 z-10 flex sm:hidden"
square
padded
@click="isDrawerActive = false"
/>
</div>
<UAccordion
:items="[{ label: '字幕选项' }]"
color="gray"
size="lg"
>
<template #item>
<div
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
<div class="flex flex-col">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
<div class="w-full flex flex-col justify-center">
<div
class="rounded-md w-full aspect-video relative overflow-hidden"
>
<img
class="object-cover w-full h-full rounded-md"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="absolute font-sans font-bold bottom-0 left-1/2 transform -translate-x-1/2 subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-sm'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
字幕编辑器
</h3>
<h3
class="text-xs font-semibold text-blue-500"
v-if="course.title"
>
{{ course.title }}
</h3>
</div>
</template>
<div
v-if="isLoading"
class="text-primary flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur
in="SourceGraphic"
result="y"
stdDeviation="1"
/>
<feColorMatrix
in="y"
result="z"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"
/>
<feBlend
in="SourceGraphic"
in2="z"
/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle
cx="5"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="5;8;5"
/>
</circle>
<circle
cx="19"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="19;16;19"
/>
</circle>
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"
/>
</g>
</svg>
</div>
<div
v-else
class="overshadow flex h-full flex-col gap-2 overflow-hidden overscroll-y-none"
>
<div class="relative aspect-video w-full flex-1">
<div
class="subtitle absolute inset-x-0 mx-auto w-fit font-sans font-bold"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-xs'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
{{ subtitles.find((sub) => sub.active)?.text }}
</div>
<video
controls
ref="videoElement"
class="rounded-xs"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
/>
</div>
<UAccordion
:items="[{ label: '字幕选项' }]"
color="gray"
size="lg"
>
<template #content>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<div class="flex w-full flex-col justify-center">
<div
class="relative aspect-video w-full overflow-hidden rounded-md"
>
字幕样式预览
<img
class="h-full w-full rounded-md object-cover"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="subtitle absolute bottom-0 left-1/2 -translate-x-1/2 transform font-sans font-bold"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-xs'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
字幕样式预览
</span>
</div>
<span class="text-2xs font-medium italic opacity-50 mt-1">
字幕预览仅供参考以实际渲染效果为准
</span>
</div>
<span class="text-sm italic opacity-50">
字幕预览仅供参考以实际渲染效果为准
</span>
<UForm
:schema="subtitleStyleSchema"
:state="subtitleStyleState"
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormField
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
{
label: '黑色',
value: '#000',
},
{
label: '白色',
value: '#fff',
},
]"
value-key="value"
v-model="subtitleStyleState.color"
/>
</UFormField>
<UFormField
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
{
label: '阴影',
value: 'shadow-xs',
},
{
label: '描边',
value: 'stroke',
},
]"
value-key="value"
v-model="subtitleStyleState.effect"
/>
</UFormField>
</div>
<UFormField
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<USlider
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormField>
<UFormField
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<USlider
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormField>
</UForm>
</div>
<UForm
:schema="subtitleStyleSchema"
:state="subtitleStyleState"
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormGroup
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:options="[
{
label: '黑色',
value: '#000',
},
{
label: '白色',
value: '#fff',
},
]"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.color"
/>
</UFormGroup>
<UFormGroup
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:options="[
{
label: '阴影',
value: 'shadow-sm',
},
{
label: '描边',
value: 'stroke',
},
]"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.effect"
/>
</UFormGroup>
</div>
<UFormGroup
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<URange
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormGroup>
<UFormGroup
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<URange
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormGroup>
</UForm>
</div>
</template>
</UAccordion>
<ul
class="flex-1 px-0.5 pb-[100%] overflow-y-auto space-y-0.5 scroll-smooth relative"
>
<li
v-for="(subtitle, index) in subtitles"
:key="index"
:id="'subtitle-' + index"
</template>
</UAccordion>
<ul
class="relative flex-1 space-y-0.5 overflow-y-auto scroll-smooth px-0.5 pb-[100%]"
>
<div :class="{ 'text-primary': subtitle.active }">
<span class="text-xs font-medium opacity-60">
{{ formatTimeToDayjs(subtitle.start).format('HH:mm:ss') }}
-
{{ formatTimeToDayjs(subtitle.end).format('HH:mm:ss') }}
<span class="opacity-50">
[{{
formatTimeToDayjs(subtitle.end).diff(
formatTimeToDayjs(subtitle.start),
'second'
)
}}s]
<li
v-for="(subtitle, index) in subtitles"
:key="index"
:id="'subtitle-' + index"
>
<div :class="{ 'text-primary': subtitle.active }">
<span class="text-xs font-medium opacity-60">
{{ formatTimeToDayjs(subtitle.start).format('HH:mm:ss') }}
-
{{ formatTimeToDayjs(subtitle.end).format('HH:mm:ss') }}
<span class="opacity-50">
[{{
formatTimeToDayjs(subtitle.end).diff(
formatTimeToDayjs(subtitle.start),
'second'
)
}}s]
</span>
</span>
</span>
<UInput
v-model="subtitle.text"
class="w-full"
placeholder="请输入字幕内容"
:name="'subtitle-' + index"
:autofocus="false"
:color="subtitle.active ? 'primary' : undefined"
@click="onSubtitleInputClick(subtitle)"
@input="
() => {
if (!modified) modified = true
}
"
>
<template #trailing>
<UIcon
v-show="subtitle.active"
name="tabler:keyframe-align-vertical-filled"
/>
</template>
</UInput>
</div>
</li>
</ul>
</div>
<template #footer>
<div class="flex justify-end items-center gap-2">
<span
v-if="modified"
class="text-sm text-yellow-500 font-medium"
>
已更改但未保存
</span>
<UButton
:loading="isExporting"
variant="soft"
icon="i-tabler-file-export"
@click="exportVideo"
>
导出视频
</UButton>
<UButton
:disabled="isExporting || !modified"
:loading="isSaving"
icon="i-tabler-device-floppy"
@click="saveNewSubtitle"
>
保存{{ isSaving ? '中' : '' }}
</UButton>
<UInput
v-model="subtitle.text"
class="w-full"
placeholder="请输入字幕内容"
:name="'subtitle-' + index"
:autofocus="false"
:color="subtitle.active ? 'primary' : undefined"
@click="onSubtitleInputClick(subtitle)"
@input="
() => {
if (!modified) modified = true
}
"
>
<template #trailing>
<UIcon
v-show="subtitle.active"
name="tabler:keyframe-align-vertical-filled"
/>
</template>
</UInput>
</div>
</li>
</ul>
</div>
</template>
</UCard>
<template #footer>
<div class="flex items-center justify-end gap-2">
<span
v-if="modified"
class="text-sm font-medium text-yellow-500"
>
已更改但未保存
</span>
<UButton
:loading="isExporting"
variant="soft"
icon="i-tabler-file-export"
@click="exportVideo"
>
导出视频
</UButton>
<UButton
:disabled="isExporting || !modified"
:loading="isSaving"
icon="i-tabler-device-floppy"
@click="saveNewSubtitle"
>
保存{{ isSaving ? '中' : '' }}
</UButton>
</div>
</template>
</UCard>
</template>
</USlideover>
</div>
</template>
<style scoped>
@reference '@/assets/css/main.css';
.overshadow {
@apply relative;
}
@@ -606,7 +609,7 @@ defineExpose({
content: '';
inset: 80% 0 0;
position: absolute;
@apply bg-linear-to-b from-transparent to-white dark:to-neutral-950 pointer-events-none;
@apply bg-linear-to-b pointer-events-none from-transparent to-white dark:to-neutral-950;
}
.subtitle.stroke {

View File

@@ -38,17 +38,17 @@ const closePreview = () => {
<template>
<div
class="relative w-full flex flex-col rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden shadow-none hover:shadow-sm transition-shadow"
class="hover:shadow-xs relative flex w-full flex-col overflow-hidden rounded-lg border border-neutral-200 shadow-none transition-shadow dark:border-neutral-700"
>
<div class="relative w-full aspect-16/9 group">
<div class="aspect-16/9 group relative w-full">
<NuxtImg
placeholder
placeholder-class="w-full aspect-16/9 object-cover bg-neutral-200 dark:bg-neutral-800"
class="object-cover relative"
class="relative object-cover"
:src="data.opening_url"
/>
<div
class="absolute inset-0 bg-black/10 backdrop-blur-md opacity-0 group-hover:opacity-100 duration-300 flex flex-col gap-2 justify-center items-center"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/10 opacity-0 backdrop-blur-md duration-300 group-hover:opacity-100"
>
<UButton
icon="tabler:play"
@@ -66,10 +66,10 @@ const closePreview = () => {
/>
</div>
</div>
<div class="relative p-2 flex justify-between items-center gap-2">
<div class="relative flex items-center justify-between gap-2 p-2">
<div class="flex-1">
<h1
class="text-base font-medium line-clamp-1"
class="line-clamp-1 text-base font-medium"
:title="data.title"
>
{{ data.title }}
@@ -85,7 +85,8 @@ const closePreview = () => {
>
<UButton
label="使用模板"
color="white"
color="neutral"
variant="outline"
@click="emit('user-titles-request', data)"
/>
<!-- <UButton
@@ -97,11 +98,11 @@ const closePreview = () => {
<UPopover v-if="loginState.user.auth_code === 2">
<UButton
icon="tabler:trash"
color="red"
color="error"
/>
<template #panel="{ close }">
<div class="flex flex-col p-2 gap-2">
<div class="flex flex-col gap-2 p-2">
<p class="text-xs text-gray-500 dark:text-gray-400">
素材删除后不可恢复确认删除
</p>
@@ -109,7 +110,7 @@ const closePreview = () => {
class="w-fit"
icon="tabler:trash"
label="确认删除"
color="red"
color="error"
size="xs"
@click="emit('system-titles-delete', data)"
/>
@@ -130,12 +131,12 @@ const closePreview = () => {
icon="tabler:trash"
label="删除素材"
variant="soft"
color="red"
color="error"
size="xs"
/>
<template #panel="{ close }">
<div class="flex flex-col p-2 gap-2">
<div class="flex flex-col gap-2 p-2">
<p class="text-xs text-gray-500 dark:text-gray-400">
素材删除后不可恢复确认删除
</p>
@@ -143,7 +144,7 @@ const closePreview = () => {
class="w-fit"
icon="tabler:trash"
label="确认删除"
color="red"
color="error"
size="xs"
@click="emit('user-titles-delete', data)"
/>
@@ -155,40 +156,37 @@ const closePreview = () => {
</div>
<UModal
v-model="isPreviewModalOpen"
:ui="{ width: 'w-full sm:max-w-4xl' }"
v-model:open="isPreviewModalOpen"
:ui="{ content: 'w-full sm:max-w-4xl' }"
>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>视频预览</p>
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
<p>视频预览</p>
</div>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
</template>
<video
v-if="previewVideoUrl"
class="w-full rounded-sm shadow-sm"
controls
autoplay
:src="previewVideoUrl"
></video>
</UCard>
<video
v-if="previewVideoUrl"
class="rounded-xs shadow-xs w-full"
controls
autoplay
:src="previewVideoUrl"
></video>
</UCard>
</template>
</UModal>
</div>
</template>