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

@@ -14,7 +14,7 @@ const props = defineProps({
},
bubbleColor: {
type: String,
default: 'primary-500',
default: 'primary',
},
})
</script>

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { DatePicker as VCalendarDatePicker } from 'v-calendar'
// @ts-ignore
import type {
DatePickerDate,
DatePickerRangeObject,
} from 'v-calendar/dist/types/src/use/datePicker'
import 'v-calendar/dist/style.css'
import type {
DatePickerRangeObject,
DatePickerDate,
} from 'v-calendar/dist/types/src/use/datePicker.js'
defineOptions({
inheritAttrs: false,
@@ -65,6 +64,8 @@ function onDayClick(_: any, event: MouseEvent): void {
</template>
<style>
@reference '@/assets/css/main.css';
:root {
--vc-gray-50: rgb(var(--color-gray-50));
--vc-gray-100: rgb(var(--color-gray-100));

View File

@@ -65,7 +65,7 @@ const handleVideoUpload = (files: FileList) => {
toast.add({
title: '文件格式错误',
description: '仅支持MP4和MOV格式的视频文件',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -76,7 +76,7 @@ const handleVideoUpload = (files: FileList) => {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过1GB',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -86,7 +86,7 @@ const handleVideoUpload = (files: FileList) => {
toast.add({
title: '文件上传成功',
description: '数字人视频已选择',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
}
@@ -101,7 +101,7 @@ const handleAuthVideoUpload = (files: FileList) => {
toast.add({
title: '文件格式错误',
description: '仅支持MP4和MOV格式的视频文件',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -112,7 +112,7 @@ const handleAuthVideoUpload = (files: FileList) => {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过1GB',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -122,7 +122,7 @@ const handleAuthVideoUpload = (files: FileList) => {
toast.add({
title: '文件上传成功',
description: '授权视频已选择',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
}
@@ -133,7 +133,7 @@ const onSubmit = async (event: FormSubmitEvent<typeof formState>) => {
if (!videoFile.value) {
toast.add({
title: '请上传数字人视频素材',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -142,7 +142,7 @@ const onSubmit = async (event: FormSubmitEvent<typeof formState>) => {
if (!authVideoFile.value) {
toast.add({
title: '请上传形象授权视频',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
@@ -186,7 +186,7 @@ const onSubmit = async (event: FormSubmitEvent<typeof formState>) => {
toast.add({
title: '数字人定制提交成功',
description: '您的数字人定制请求已提交,请等待管理员处理',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
@@ -210,7 +210,7 @@ const onSubmit = async (event: FormSubmitEvent<typeof formState>) => {
toast.add({
title: '提交失败',
description: errorMessage,
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
} finally {
@@ -243,312 +243,317 @@ const showAuthModal = ref(false)
<template>
<UModal
v-model="isOpen"
:ui="{ width: 'sm:max-w-6xl' }"
v-model:open="isOpen"
:ui="{ content: 'sm:max-w-6xl' }"
>
<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-lg font-semibold text-gray-900 dark:text-white">
数字人定制
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="isOpen = false"
/>
</div>
</template>
<div class="grid grid-cols-7 gap-6">
<!-- 左侧表单 -->
<div class="col-span-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
<UForm
:schema="schema"
:state="formState"
class="space-y-4"
@submit="onSubmit"
>
<!-- 数字人视频素材 -->
<UFormGroup
label="数字人视频素材"
required
>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-video-camera"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ videoFile ? videoFile.name : '点击或拖拽上传视频' }}
</span>
</div>
<p class="text-xs text-gray-500 mt-1">
小于 1GB mov/mp4 格式比例 9:16帧率 25FPS分辨率
1080P时长 3-6 分钟
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<!-- 数字人名称 -->
<UFormGroup
label="数字人名称"
name="dh_name"
required
>
<UInput
v-model="formState.dh_name"
placeholder="请输入数字人名称"
/>
</UFormGroup>
<!-- 单位名称 -->
<UFormGroup
label="单位名称"
name="organization"
required
>
<UInput
v-model="formState.organization"
placeholder="请输入单位名称"
/>
</UFormGroup>
<!-- 形象授权视频 -->
<UFormGroup
label="形象授权视频"
required
>
<template #description>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">
请确保本人进行形象授权视频录制否则脸部比对将不通过导致制作失败
</span>
<UButton
variant="link"
size="xs"
icon="i-heroicons-document-text"
@click="showAuthModal = true"
>
授权文案
</UButton>
</div>
</template>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleAuthVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-shield-check"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
authVideoFile
? authVideoFile.name
: '点击或拖拽上传授权视频'
}}
</span>
</div>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<!-- 提交按钮 -->
<UButton
type="submit"
class="w-full"
:loading="isSubmitting"
:disabled="isSubmitting"
color="primary"
>
{{ isSubmitting ? '提交中...' : '确认提交' }}
</UButton>
<!-- 上传进度 -->
<div
v-if="isSubmitting"
class="mt-4 space-y-2"
>
<div class="flex justify-between text-sm">
<span>{{ uploadProgress.message }}</span>
<span>
{{ uploadProgress.step }}/{{ uploadProgress.total }}
</span>
</div>
<UProgress
:value="(uploadProgress.step / uploadProgress.total) * 100"
color="primary"
/>
</div>
</UForm>
</div>
<!-- 右侧教程和提示 -->
<div class="col-span-4 p-4 rounded-lg border dark:border-gray-700">
<div class="flex flex-col h-full gap-6">
<!-- 教程视频 -->
<div class="flex-1">
<h3
class="text-lg font-semibold mb-3 text-gray-800 dark:text-white flex items-center gap-2"
>
<UIcon
name="i-heroicons-video-camera"
class="h-5 w-5"
/>
视频录制教程
</h3>
<div
class="w-full aspect-video border rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
>
<UIcon
name="i-heroicons-video-camera"
class="h-12 w-12 text-gray-400"
/>
</div>
</div>
<!-- 联系方式 -->
<div
class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-700"
>
<div class="flex items-center gap-3">
<div class="bg-blue-100 dark:bg-blue-900 p-2 rounded-lg">
<UIcon
name="i-heroicons-chat-bubble-left-right"
class="h-5 w-5 text-blue-600 dark:text-blue-400"
/>
</div>
<div>
<p class="text-sm font-medium text-gray-800 dark:text-white">
需要帮助
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
客服微信
<span class="font-mono text-blue-600 dark:text-blue-400">
xxxxxx
</span>
</p>
</div>
</div>
</div>
<!-- 录制指南 -->
<div
class="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-700"
>
<div class="flex items-start gap-3">
<div
class="bg-amber-100 dark:bg-amber-900 p-2 rounded-lg mt-0.5"
>
<UIcon
name="i-heroicons-light-bulb"
class="h-5 w-5 text-amber-600 dark:text-amber-400"
/>
</div>
<div class="flex-1">
<h4
class="text-sm font-semibold text-gray-800 dark:text-white mb-3"
>
录制注意事项
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-sun"
class="h-4 w-4 text-amber-500 mt-0.5 shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
确保光线充足避免背光
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-speaker-wave"
class="h-4 w-4 text-green-500 mt-0.5 shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
选择安静环境减少噪音干扰
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-viewfinder-circle"
class="h-4 w-4 text-blue-500 mt-0.5 shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
人脸占画面比例控制在 1/4 以内
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-face-smile"
class="h-4 w-4 text-purple-500 mt-0.5 shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
保持自然表情使用恰当手势
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
<!-- 授权文案弹窗 -->
<UModal v-model="showAuthModal">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
授权视频文案
数字人定制
</h3>
<UButton
color="gray"
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="showAuthModal = false"
@click="isOpen = false"
/>
</div>
</template>
<div class="p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
请确保您是视频中人物的合法授权人在授权视频中朗读以下文案
</p>
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4">
<p class="text-sm leading-relaxed text-gray-800 dark:text-gray-200">
我是在"AI智慧职教平台"定制上传视频的模特本人我承诺已经按照平台规则进行合法授权特此承诺
</p>
<div class="grid grid-cols-7 gap-6">
<!-- 左侧表单 -->
<div class="col-span-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<UForm
:schema="schema"
:state="formState"
class="space-y-4"
@submit="onSubmit"
>
<!-- 数字人视频素材 -->
<UFormField
label="数字人视频素材"
required
>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-video-camera"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
videoFile ? videoFile.name : '点击或拖拽上传视频'
}}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
小于 1GB mov/mp4 格式比例 9:16帧率 25FPS分辨率
1080P时长 3-6 分钟
</p>
</div>
</template>
</UniFileDnD>
</UFormField>
<!-- 数字人名称 -->
<UFormField
label="数字人名称"
name="dh_name"
required
>
<UInput
v-model="formState.dh_name"
placeholder="请输入数字人名称"
/>
</UFormField>
<!-- 单位名称 -->
<UFormField
label="单位名称"
name="organization"
required
>
<UInput
v-model="formState.organization"
placeholder="请输入单位名称"
/>
</UFormField>
<!-- 形象授权视频 -->
<UFormField
label="形象授权视频"
required
>
<template #description>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">
请确保本人进行形象授权视频录制否则脸部比对将不通过导致制作失败
</span>
<UButton
variant="link"
size="xs"
icon="i-heroicons-document-text"
@click="showAuthModal = true"
>
授权文案
</UButton>
</div>
</template>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleAuthVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-shield-check"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
authVideoFile
? authVideoFile.name
: '点击或拖拽上传授权视频'
}}
</span>
</div>
</div>
</template>
</UniFileDnD>
</UFormField>
<!-- 提交按钮 -->
<UButton
type="submit"
class="w-full"
:loading="isSubmitting"
:disabled="isSubmitting"
color="primary"
>
{{ isSubmitting ? '提交中...' : '确认提交' }}
</UButton>
<!-- 上传进度 -->
<div
v-if="isSubmitting"
class="mt-4 space-y-2"
>
<div class="flex justify-between text-sm">
<span>{{ uploadProgress.message }}</span>
<span>
{{ uploadProgress.step }}/{{ uploadProgress.total }}
</span>
</div>
<UProgress
:value="(uploadProgress.step / uploadProgress.total) * 100"
color="primary"
/>
</div>
</UForm>
</div>
<!-- 右侧教程和提示 -->
<div class="col-span-4 rounded-lg border p-4 dark:border-gray-700">
<div class="flex h-full flex-col gap-6">
<!-- 教程视频 -->
<div class="flex-1">
<h3
class="mb-3 flex items-center gap-2 text-lg font-semibold text-gray-800 dark:text-white"
>
<UIcon
name="i-heroicons-video-camera"
class="h-5 w-5"
/>
视频录制教程
</h3>
<div
class="flex aspect-video w-full items-center justify-center rounded-lg border bg-gray-100 dark:bg-gray-800"
>
<UIcon
name="i-heroicons-video-camera"
class="h-12 w-12 text-gray-400"
/>
</div>
</div>
<!-- 联系方式 -->
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/20"
>
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900">
<UIcon
name="i-heroicons-chat-bubble-left-right"
class="h-5 w-5 text-blue-600 dark:text-blue-400"
/>
</div>
<div>
<p
class="text-sm font-medium text-gray-800 dark:text-white"
>
需要帮助
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
客服微信
<span class="font-mono text-blue-600 dark:text-blue-400">
xxxxxx
</span>
</p>
</div>
</div>
</div>
<!-- 录制指南 -->
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/20"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 rounded-lg bg-amber-100 p-2 dark:bg-amber-900"
>
<UIcon
name="i-heroicons-light-bulb"
class="h-5 w-5 text-amber-600 dark:text-amber-400"
/>
</div>
<div class="flex-1">
<h4
class="mb-3 text-sm font-semibold text-gray-800 dark:text-white"
>
录制注意事项
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-sun"
class="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
确保光线充足避免背光
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-speaker-wave"
class="mt-0.5 h-4 w-4 shrink-0 text-green-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
选择安静环境减少噪音干扰
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-viewfinder-circle"
class="mt-0.5 h-4 w-4 shrink-0 text-blue-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
人脸占画面比例控制在 1/4 以内
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-face-smile"
class="mt-0.5 h-4 w-4 shrink-0 text-purple-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
保持自然表情使用恰当手势
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
</UModal>
<!-- 授权文案弹窗 -->
<UModal v-model:open="showAuthModal">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
授权视频文案
</h3>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="showAuthModal = false"
/>
</div>
</template>
<div class="p-4">
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
请确保您是视频中人物的合法授权人在授权视频中朗读以下文案
</p>
<div class="rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<p
class="text-sm leading-relaxed text-gray-800 dark:text-gray-200"
>
我是在"AI智慧职教平台"定制上传视频的模特本人我承诺已经按照平台规则进行合法授权特此承诺
</p>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
</UModal>
</template>

View File

@@ -14,7 +14,8 @@ defineProps({
},
})
const modal = useModal()
const overlay = useOverlay()
const modal = overlay.create(ModalAuthentication)
</script>
<template>
@@ -30,10 +31,10 @@ const modal = useModal()
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton
class="mt-2 font-bold"
color="black"
color="primary"
size="xs"
variant="solid"
@click="modal.open(ModalAuthentication)"
@click="modal.open()"
>
登录
</UButton>

View File

@@ -4,8 +4,9 @@ import { object, string, type InferType } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['close'])
const toast = useToast()
const modal = useModal()
const overlay = useOverlay()
const loginState = useLoginState()
const sms_triggered = ref(false)
@@ -55,7 +56,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -64,7 +65,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: res.msg || '账号或密码错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -73,7 +74,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: res.msg || '无法获取登录状态',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -84,7 +85,8 @@ function onSubmit(form: req.user.Login) {
.updateProfile()
.then(() => {
loginState.checkSession()
modal.close()
// TODO: only close the specific modal
overlay.closeAll()
toast.add({
title: '登录成功',
description: `${loginState.user.username}, 欢迎回来`,
@@ -96,7 +98,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
})
@@ -108,7 +110,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
})
@@ -130,7 +132,7 @@ const obtainSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -140,7 +142,7 @@ const obtainSmsCode = () => {
sms_counting_down.value = 60 // TODO: save timestamp to localstorage
toast.add({
title: '短信验证码已发送',
color: 'indigo',
color: 'primary',
icon: 'i-tabler-circle-check',
})
const interval = setInterval(() => {
@@ -154,7 +156,7 @@ const obtainSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: err.msg || '网络错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
})
@@ -175,7 +177,7 @@ const handle_sms_verify = (e: string[]) => {
toast.add({
title: '登录失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -185,7 +187,7 @@ const handle_sms_verify = (e: string[]) => {
toast.add({
title: '登录失败',
description: res.msg || '无法获取登录状态',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -196,7 +198,7 @@ const handle_sms_verify = (e: string[]) => {
.updateProfile()
.then(() => {
loginState.checkSession()
modal.close()
emit('close')
toast.add({
title: '登录成功',
description: `${loginState.user.username}, 欢迎回来`,
@@ -208,7 +210,7 @@ const handle_sms_verify = (e: string[]) => {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
})
@@ -251,7 +253,7 @@ const obtainForgetSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -261,7 +263,7 @@ const obtainForgetSmsCode = () => {
sms_counting_down.value = 60 // TODO: save timestamp to localstorage
toast.add({
title: '短信验证码已发送',
color: 'indigo',
color: 'primary',
icon: 'i-tabler-circle-check',
})
const interval = setInterval(() => {
@@ -275,7 +277,7 @@ const obtainForgetSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: err.msg || '网络错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
})
@@ -299,7 +301,7 @@ const onForgetPasswordSubmit = (
toast.add({
title: '重置密码失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -307,7 +309,7 @@ const onForgetPasswordSubmit = (
toast.add({
title: '重置密码成功',
description: '请您继续登录',
color: 'green',
color: 'success',
icon: 'i-tabler-circle-check',
})
currentTab.value = 1
@@ -316,7 +318,7 @@ const onForgetPasswordSubmit = (
toast.add({
title: '重置密码失败',
description: err.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
})
@@ -324,173 +326,101 @@ const onForgetPasswordSubmit = (
</script>
<template>
<UModal prevent-close>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
登录眩生花 AI 助手
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="modal.close()"
/>
</div>
</template>
<UTabs
:items="items"
class="w-full"
v-model="currentTab"
>
<template #default="{ item, index, selected }">
<div class="flex items-center gap-2 relative truncate">
<span class="truncate">{{ item.label }}</span>
<span
v-if="selected"
class="absolute -right-4 w-2 h-2 rounded-full bg-primary-500 dark:bg-primary-400"
<UModal :dismissible="false">
<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"
>
登录眩生花 AI 助手
</h3>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="$emit('close')"
/>
</div>
</template>
<template #item="{ item }">
<UCard @submit.prevent="() => onSubmit(accountForm)">
<template #header>
<p
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{ item.label }}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ item.description }}
</p>
</template>
<div
v-if="item.key === 'account'"
class="space-y-3"
>
<UFormGroup
label="用户名"
name="username"
required
>
<UInput
v-model="accountForm.username"
:disabled="final_loading"
required
/>
</UFormGroup>
<UFormGroup
label="密码"
name="password"
required
>
<UInput
v-model="accountForm.password"
:disabled="final_loading"
type="password"
required
/>
</UFormGroup>
<UTabs
:items="items"
class="w-full"
v-model="currentTab"
>
<!-- <template #default="{ item, index, selected }">
<div class="relative flex items-center gap-2 truncate">
<span class="truncate">{{ item.label }}</span>
<span
v-if="selected"
class="bg-primary-500 dark:bg-primary-400 absolute -right-4 h-2 w-2 rounded-full"
/>
</div>
</template> -->
<template #content="{ item }">
<UCard @submit.prevent="() => onSubmit(accountForm)">
<template #header>
<p
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
{{ item.label }}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ item.description }}
</p>
</template>
<div
v-else-if="item.key === 'sms'"
class="space-y-3"
>
<UFormGroup
label="手机号"
name="mobile"
required
>
<UButtonGroup class="w-full">
<UInput
v-model="smsForm.mobile"
:disabled="final_loading"
type="sms"
class="w-full"
required
>
<template #leading>
<span class="text-gray-500 dark:text-gray-400 text-xs">
+86
</span>
</template>
</UInput>
<UButton
:label="
sms_counting_down
? `${sms_counting_down}秒后重发`
: '获取验证码'
"
@click="obtainSmsCode"
:loading="sms_sending"
:disabled="!!sms_counting_down || final_loading"
class="text-xs font-bold"
color="gray"
/>
</UButtonGroup>
</UFormGroup>
<Transition name="pin-root">
<div v-if="sms_triggered">
<Label
for="pin-input"
class="pin-label"
>
验证码
</Label>
<PinInputRoot
id="sms-input"
v-model="smsForm.sms_code"
:disabled="sms_sending || final_loading"
placeholder="○"
class="w-full flex gap-2 justify-between md:justify-start items-center mt-1"
@complete="handle_sms_verify"
type="number"
otp
required
>
<PinInputInput
v-for="(id, index) in 4"
:key="id"
:index="index"
class="pin-input"
:autofocus="index === 0"
/>
</PinInputRoot>
</div>
</Transition>
</div>
<div
v-if="item.key === 'recovery'"
class="space-y-3"
>
<UForm
<div
v-if="item.key === 'account'"
class="space-y-3"
:schema="forgetPasswordSchema"
:state="forgetPasswordState"
@submit="onForgetPasswordSubmit"
>
<UFormGroup
<UFormField
label="用户名"
name="username"
required
>
<UInput
v-model="accountForm.username"
:disabled="final_loading"
required
/>
</UFormField>
<UFormField
label="密码"
name="password"
required
>
<UInput
v-model="accountForm.password"
:disabled="final_loading"
type="password"
required
/>
</UFormField>
</div>
<div
v-else-if="item.key === 'sms'"
class="space-y-3"
>
<UFormField
label="手机号"
name="mobile"
required
>
<UButtonGroup class="w-full">
<UInput
v-model="forgetPasswordState.mobile"
v-model="smsForm.mobile"
:disabled="final_loading"
type="tel"
type="sms"
class="w-full"
required
>
<template #leading>
<span class="text-gray-500 dark:text-gray-400 text-xs">
<span class="text-xs text-gray-500 dark:text-gray-400">
+86
</span>
</template>
@@ -501,78 +431,156 @@ const onForgetPasswordSubmit = (
? `${sms_counting_down}秒后重发`
: '获取验证码'
"
@click="obtainForgetSmsCode"
@click="obtainSmsCode"
:loading="sms_sending"
:disabled="!!sms_counting_down"
:disabled="!!sms_counting_down || final_loading"
class="text-xs font-bold"
color="gray"
color="neutral"
/>
</UButtonGroup>
</UFormGroup>
<UFormGroup
label="验证码"
name="sms_code"
required
>
<UInput
v-model="forgetPasswordState.sms_code"
type="sms"
class="w-full"
:disabled="final_loading"
/>
</UFormGroup>
<UFormGroup
label="新密码"
name="password"
required
>
<UInput
v-model="forgetPasswordState.password"
type="password"
:disabled="final_loading"
/>
</UFormGroup>
</UFormField>
<Transition name="pin-root">
<div v-if="sms_triggered">
<Label
for="pin-input"
class="pin-label"
>
验证码
</Label>
<PinInputRoot
id="sms-input"
v-model="smsForm.sms_code"
:disabled="sms_sending || final_loading"
placeholder="○"
class="mt-1 flex w-full items-center justify-between gap-2 md:justify-start"
@complete="handle_sms_verify"
type="number"
otp
required
>
<PinInputInput
v-for="(id, index) in 4"
:key="id"
:index="index"
class="pin-input"
:autofocus="index === 0"
/>
</PinInputRoot>
</div>
</Transition>
</div>
<div>
<div
v-if="item.key === 'recovery'"
class="space-y-3"
>
<UForm
class="space-y-3"
:schema="forgetPasswordSchema"
:state="forgetPasswordState"
@submit="onForgetPasswordSubmit"
>
<UFormField
label="手机号"
name="mobile"
required
>
<UButtonGroup class="w-full">
<UInput
v-model="forgetPasswordState.mobile"
:disabled="final_loading"
type="tel"
class="w-full"
>
<template #leading>
<span
class="text-xs text-gray-500 dark:text-gray-400"
>
+86
</span>
</template>
</UInput>
<UButton
:label="
sms_counting_down
? `${sms_counting_down}秒后重发`
: '获取验证码'
"
@click="obtainForgetSmsCode"
:loading="sms_sending"
:disabled="!!sms_counting_down"
class="text-xs font-bold"
color="neutral"
/>
</UButtonGroup>
</UFormField>
<UFormField
label="验证码"
name="sms_code"
required
>
<UInput
v-model="forgetPasswordState.sms_code"
type="sms"
class="w-full"
:disabled="final_loading"
/>
</UFormField>
<UFormField
label="新密码"
name="password"
required
>
<UInput
v-model="forgetPasswordState.password"
type="password"
:disabled="final_loading"
/>
</UFormField>
<div>
<UButton
type="submit"
:loading="final_loading"
>
重置密码
</UButton>
</div>
</UForm>
</div>
<template
#footer
v-if="item.key === 'account'"
>
<div class="flex items-center justify-between">
<UButton
type="submit"
color="primary"
:loading="final_loading"
>
重置密码
登录
</UButton>
<UButton
variant="link"
color="neutral"
@click="currentTab = 2"
>
忘记密码
</UButton>
</div>
</UForm>
</div>
<template
#footer
v-if="item.key === 'account'"
>
<div class="flex items-center justify-between">
<UButton
type="submit"
color="black"
:loading="final_loading"
>
登录
</UButton>
<UButton
variant="link"
color="gray"
@click="currentTab = 2"
>
忘记密码
</UButton>
</div>
</template>
</UCard>
</template>
</UTabs>
</UCard>
</template>
</UCard>
</template>
</UTabs>
</UCard>
</template>
</UModal>
</template>
<style scoped>
@reference '@/assets/css/main.css';
.pin-root-enter-active,
.pin-root-leave-active {
@apply transition duration-500;
@@ -580,15 +588,15 @@ const onForgetPasswordSubmit = (
.pin-root-enter-from,
.pin-root-leave-to {
@apply opacity-0 -translate-y-2;
@apply -translate-y-2 opacity-0;
}
.pin-input {
@apply w-full md:w-16 aspect-square rounded text-center shadow caret-transparent;
@apply outline-0 ring-indigo-500 focus:ring-3 font-bold;
@apply aspect-square w-full rounded text-center caret-transparent shadow md:w-16;
@apply focus:ring-3 font-bold outline-0 ring-indigo-500;
}
.pin-label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-200 after:content-['*'] after:ms-0.5 after:text-red-500 dark:after:text-red-400;
@apply block text-sm font-medium text-gray-700 after:ms-0.5 after:text-red-500 after:content-['*'] dark:text-gray-200 dark:after:text-red-400;
}
</style>

View File

@@ -27,16 +27,17 @@ const emit = defineEmits({
})
const loginState = useLoginState()
const modal = useModal()
const toast = useToast()
const page = ref(1)
const isRealOpen = computed(() => props.isOpen)
const sourceTypeList = [
{ label: 'xsh_wm', value: 1, color: 'blue' }, // 万木(腾讯)
{ label: 'xsh_zy', value: 2, color: 'green' }, // XSH 自有
{ label: 'xsh_fh', value: 3, color: 'purple' }, // 硅基(泛化数字人)
{ label: 'xsh_bb', value: 4, color: 'indigo' }, // 百度小冰
{ label: 'xsh_wm', value: 1, color: 'info' }, // 万木(腾讯)
{ label: 'xsh_zy', value: 2, color: 'success' }, // XSH 自有
{ label: 'xsh_fh', value: 3, color: 'warning' }, // 硅基(泛化数字人)
{ label: 'xsh_bb', value: 4, color: 'primary' }, // 百度小冰
]
// const sourceType = ref(sourceTypeList[0])
@@ -59,7 +60,7 @@ const handleClose = () => {
if (props.isOpen) {
emit('close')
} else {
modal.close()
emit('close')
}
}
@@ -68,7 +69,7 @@ const handleSubmit = () => {
toast.add({
title: '请选择数字人',
description: '请至少选择一个数字人',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -77,7 +78,7 @@ const handleSubmit = () => {
'select',
props.multiple
? selectedDigitalHumans.value
: selectedDigitalHumans.value[0]
: selectedDigitalHumans.value[0]!
)
handleClose()
setTimeout(() => {
@@ -153,113 +154,111 @@ onMounted(() => {
<template>
<UModal
:model-value="isOpen"
:ui="{ width: 'w-full sm:max-w-3xl' }"
v-model:open="isRealOpen"
:ui="{ content: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<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"
>
数字人选择器
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<UTabs
v-model="tabIndex"
:items="tabItems"
>
<template #item="{ item }">
<div class="w-full grid grid-cols-3 sm:grid-cols-5 gap-4">
<div
v-for="(d, i) in item.key === 'user'
? userDigitalList?.data.items
: systemDigitalList?.data.items"
:key="`${item.key === 'user' ? 'user' : 'system'}-digital-${
d.model_id
}`"
:class="{
'border-primary shadow-md': selectedDigitalHumans.includes(d),
'border-neutral-200 dark:border-neutral-700':
!selectedDigitalHumans.includes(d),
}"
class="relative flex flex-col justify-center items-center gap-2 overflow-hidden w-full bg-white dark:bg-neutral-800 rounded-md border dark:border-2 cursor-pointer transition-all duration-150 select-none"
@click="
!disabledDigitalHumanIds.includes(d.model_id)
? handleSelectClick(d)
: void 0
"
<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"
>
数字人选择器
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<UTabs
v-model="tabIndex"
:items="tabItems"
>
<template #content="{ item }">
<div class="grid w-full grid-cols-3 gap-4 sm:grid-cols-5">
<div
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute inset-0 bg-neutral-400 dark:bg-neutral-700 bg-opacity-50 dark:bg-opacity-50 cursor-not-allowed z-10"
></div>
<div
:class="{ 'bg-primary-50': selectedDigitalHumans.includes(d) }"
class="relative bg-neutral-100 dark:bg-neutral-800 border-b dark:border-neutral-700 w-full aspect-square object-cover overflow-hidden transition-all duration-150"
v-for="(d, i) in item.key === 'user'
? userDigitalList?.data.items
: systemDigitalList?.data.items"
:key="`${item.key === 'user' ? 'user' : 'system'}-digital-${
d.model_id
}`"
:class="{
'border-primary shadow-md': selectedDigitalHumans.includes(d),
'border-neutral-200 dark:border-neutral-700':
!selectedDigitalHumans.includes(d),
}"
class="relative flex w-full cursor-pointer select-none flex-col items-center justify-center gap-2 overflow-hidden rounded-md border bg-white transition-all duration-150 dark:border-2 dark:bg-neutral-800"
@click="
!disabledDigitalHumanIds.includes(d.model_id)
? handleSelectClick(d)
: void 0
"
>
<NuxtImg
:src="d.avatar"
class="-translate-y-4"
/>
<UIcon
v-if="selectedDigitalHumans.includes(d)"
class="absolute top-1 right-1 text-lg text-primary"
name="i-tabler-check"
/>
<UIcon
<div
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute top-1 right-1 text-lg text-red-500"
name="tabler:user-off"
/>
<template
v-for="(t, i) in sourceTypeList"
:key="i"
class="absolute inset-0 z-10 cursor-not-allowed bg-neutral-400/50 dark:bg-neutral-700/50"
></div>
<div
:class="{
'bg-primary-50': selectedDigitalHumans.includes(d),
}"
class="relative aspect-square w-full overflow-hidden border-b bg-neutral-100 object-cover transition-all duration-150 dark:border-neutral-700 dark:bg-neutral-800"
>
<UBadge
v-if="t.value === d.type"
class="absolute bottom-1 right-1"
size="xs"
variant="subtle"
:color="t.color"
:label="t.label"
<NuxtImg
:src="d.avatar"
class="-translate-y-4"
/>
</template>
</div>
<div class="w-full flex flex-col gap-1 px-2 pb-2">
<div class="flex justify-between items-center">
<span
class="text-sm text-neutral-800 dark:text-neutral-300 font-medium line-clamp-1"
<UIcon
v-if="selectedDigitalHumans.includes(d)"
class="text-primary absolute right-1 top-1 text-lg"
name="i-tabler-check"
/>
<UIcon
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute right-1 top-1 text-lg text-red-500"
name="tabler:user-off"
/>
<template
v-for="(t, i) in sourceTypeList"
:key="i"
>
{{ d.name }}
</span>
<span
class="text-xs text-neutral-300 dark:text-neutral-500 font-medium"
>
ID:{{ d.digital_human_id || d.id }}
</span>
<UBadge
v-if="t.value === d.type"
class="absolute bottom-1 right-1"
size="xs"
variant="subtle"
:color="t.color as any"
:label="t.label"
/>
</template>
</div>
<div class="flex w-full flex-col gap-1 px-2 pb-2">
<div class="flex items-center justify-between">
<span
class="line-clamp-1 text-sm font-medium text-neutral-800 dark:text-neutral-300"
>
{{ d.name }}
</span>
<span
class="text-xs font-medium text-neutral-300 dark:text-neutral-500"
>
ID:{{ d.digital_human_id || d.id }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-between items-end">
<div class="flex items-center gap-2">
<!-- <span class="text-sm text-neutral-800 dark:text-neutral-300 font-medium">
<div class="flex items-end justify-between">
<div class="flex items-center gap-2">
<!-- <span class="text-sm text-neutral-800 dark:text-neutral-300 font-medium">
选择来源:
</span>
<USelectMenu
@@ -267,50 +266,51 @@ onMounted(() => {
:options="sourceTypeList.map(i => ({ label: i.label, value: i.value }))"
@change="page = 1"
/> -->
</div>
<UPagination
v-if="
(item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0) > 0
"
v-model:page="page"
:page-count="15"
:total="
item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0
"
class="pt-4"
/>
</div>
</template>
</UTabs>
<template #footer>
<div class="flex items-center justify-between">
<div>
<p class="select-none text-xs font-medium opacity-50">
如果没有出现您的数字人,请联系管理员开通
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="neutral"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
<UPagination
v-if="
(item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0) > 0
"
v-model="page"
:page-count="15"
:total="
item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0
"
class="pt-4"
/>
</div>
</template>
</UTabs>
<template #footer>
<div class="flex justify-between items-center">
<div>
<p class="text-xs font-medium opacity-50 select-none">
如果没有出现您的数字人,请联系管理员开通
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="gray"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
</div>
</template>
</UCard>
</UCard>
</template>
</UModal>
</template>

View File

@@ -12,8 +12,8 @@ const emit = defineEmits({
})
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const isRealOpen = computed(() => props.isOpen)
const pagination = reactive({
page: 1,
@@ -48,7 +48,7 @@ const handleClose = () => {
if (props.isOpen) {
emit('close')
} else {
modal.close()
emit('close')
}
}
@@ -57,7 +57,7 @@ const handleSubmit = () => {
toast.add({
title: '请选择片头',
description: '请选择一个片头',
color: 'red',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
@@ -69,118 +69,115 @@ const handleSubmit = () => {
<template>
<UModal
:model-value="isOpen"
:ui="{ width: 'w-full sm:max-w-3xl' }"
v-model:open="isRealOpen"
:ui="{ content: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<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"
>
视频片头选择器
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<div>
<div class="w-full grid grid-cols-2 sm:grid-cols-3 gap-4">
<div
v-for="(titles, i) in userTitlesTemplate?.data.items"
:key="`user-titles-${titles.id}`"
:class="{
'border-primary shadow-md': selectedTitle?.id === titles.id,
'border-neutral-200 dark:border-neutral-700':
selectedTitle?.id !== titles.id,
}"
class="relative flex flex-col justify-center items-center gap-2 overflow-hidden w-full bg-white dark:bg-neutral-800 rounded-md border dark:border-2 cursor-pointer transition-all duration-150 select-none"
@click="selectedTitle = titles"
>
<div
:class="{
'bg-primary-50': selectedTitle?.id === titles.id,
}"
class="relative bg-neutral-100 dark:bg-neutral-800 border-b dark:border-neutral-700 w-full aspect-video object-cover overflow-hidden transition-all duration-150"
<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"
>
<NuxtImg :src="titles.opening_url" />
<UIcon
v-if="selectedTitle?.id === titles.id"
class="absolute top-1 right-1 text-lg text-primary"
name="i-tabler-check"
/>
</div>
<div class="w-full flex flex-col gap-1 px-2 pb-2">
<div class="flex justify-between items-center">
<span
class="text-sm text-neutral-800 dark:text-neutral-300 font-medium line-clamp-1"
>
{{ titles.title }}
</span>
<span
class="text-xs text-neutral-300 dark:text-neutral-500 font-medium"
>
ID:{{ titles.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<UPagination
v-if="(userTitlesTemplate?.data.total || 0) > 0"
v-model="pagination.page"
:page-count="pagination.pageSize"
:total="userTitlesTemplate?.data.total || 0"
class="pt-4"
/>
</div>
</div>
<template #footer>
<div class="flex justify-between items-center">
<div>
<p class="text-xs font-medium opacity-50 select-none">
如果此处没有您的片头请在
<a
class="text-primary"
href="/generation/materials"
target="_blank"
>
片头模版库
</a>
页面确认已经制作完毕
</p>
</div>
<div class="flex items-center gap-4">
视频片头选择器
</h3>
<UButton
color="gray"
label="取消"
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
</div>
</template>
<div>
<div class="grid w-full grid-cols-2 gap-4 sm:grid-cols-3">
<div
v-for="(titles, i) in userTitlesTemplate?.data.items"
:key="`user-titles-${titles.id}`"
:class="{
'border-primary shadow-md': selectedTitle?.id === titles.id,
'border-neutral-200 dark:border-neutral-700':
selectedTitle?.id !== titles.id,
}"
class="relative flex w-full cursor-pointer select-none flex-col items-center justify-center gap-2 overflow-hidden rounded-md border bg-white transition-all duration-150 dark:border-2 dark:bg-neutral-800"
@click="selectedTitle = titles"
>
<div
:class="{
'bg-primary-50': selectedTitle?.id === titles.id,
}"
class="relative aspect-video w-full overflow-hidden border-b bg-neutral-100 object-cover transition-all duration-150 dark:border-neutral-700 dark:bg-neutral-800"
>
<NuxtImg :src="titles.opening_url" />
<UIcon
v-if="selectedTitle?.id === titles.id"
class="text-primary absolute right-1 top-1 text-lg"
name="i-tabler-check"
/>
</div>
<div class="flex w-full flex-col gap-1 px-2 pb-2">
<div class="flex items-center justify-between">
<span
class="line-clamp-1 text-sm font-medium text-neutral-800 dark:text-neutral-300"
>
{{ titles.title }}
</span>
<span
class="text-xs font-medium text-neutral-300 dark:text-neutral-500"
>
ID:{{ titles.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<UPagination
v-if="(userTitlesTemplate?.data.total || 0) > 0"
v-model:page="pagination.page"
:page-count="pagination.pageSize"
:total="userTitlesTemplate?.data.total || 0"
class="pt-4"
/>
</div>
</div>
</template>
</UCard>
<template #footer>
<div class="flex items-center justify-between">
<div>
<p class="select-none text-xs font-medium opacity-50">
如果此处没有您的片头请在
<a
class="text-primary"
href="/generation/materials"
target="_blank"
>
片头模版库
</a>
页面确认已经制作完毕
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="neutral"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@@ -5,9 +5,8 @@ import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success'])
const emit = defineEmits(['success', 'close'])
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
@@ -65,14 +64,14 @@ const onCreateCourseSubmit = async (
toast.add({
title: '未选择文件',
description: '请先选择 PPTX 文件',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
creationPending.value = true
// upload PPTX file
useFileGo(selected_file.value[0], 'ppt').then((url) => {
useFileGo(selected_file.value[0]!, 'ppt').then((url) => {
useFetchWrapped<
req.gen.CourseGenCreate & AuthedRequest,
BaseResponse<resp.gen.CourseGenCreate>
@@ -93,16 +92,16 @@ const onCreateCourseSubmit = async (
toast.add({
title: '创建成功',
description: '已加入生成队列',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
emit('success')
slide.close()
emit('close')
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
}
@@ -113,7 +112,7 @@ const onCreateCourseSubmit = async (
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
})
@@ -122,239 +121,229 @@ const onCreateCourseSubmit = async (
</script>
<template>
<USlideover prevent-close>
<UCard
:ui="{
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建微课视频
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="slide.close()"
/>
</div>
</template>
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseSubmit"
<USlideover
:dismissible="false"
title="新建微课视频"
>
<template #content>
<UCard
:ui="{
body: 'flex-1',
}"
class="flex flex-1 flex-col"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup
label="微课标题"
name="task_title"
required
>
<UInput
v-model="createCourseState.task_title"
placeholder="请输入微课标题"
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建微课视频
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="$emit('close')"
/>
</UFormGroup>
</div>
</div>
</template>
<div class="grid grid-cols-2 gap-2">
<UFormGroup
label="数字人"
name="digital_human_id"
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormField
label="微课标题"
name="task_title"
required
>
<UInput
v-model="createCourseState.task_title"
placeholder="请输入微课标题"
/>
</UFormField>
</div>
<div class="grid grid-cols-2 gap-2">
<UFormField
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
@click="isDigitalSelectorOpen = true"
>
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</UFormField>
<UFormField
label="视频片头片尾"
name="opening"
>
<div
:class="{ 'shadow-inner': !!selected_titles }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
@click="isTitlesSelectorOpen = true"
>
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
>
<UIcon
v-if="!selected_titles"
class="text-2xl opacity-50"
name="i-tabler-brackets-contain"
/>
<NuxtImg
v-else
:src="selected_titles?.opening_url"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<span :class="!!selected_titles ? 'text-neutral-600' : ''">
{{ selected_titles?.title || '点击选择片头' }}
</span>
<span
v-if="selected_titles?.description"
class="text-2xs"
>
{{ selected_titles?.description }}
</span>
</div>
</div>
</UFormField>
</div>
<UFormField
label="PPT 文件"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isDigitalSelectorOpen = true"
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</UFormGroup>
<UFormGroup
label="视频片头片尾"
name="opening"
<template #help>
<p class="text-xs text-neutral-400">仅支持 .pptx 格式</p>
</template>
<FileDnD
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
@change="(file) => (selected_file = file)"
/>
</UFormField>
<UAccordion
:items="[{ label: '高级选项' }]"
color="neutral"
size="lg"
>
<div
:class="{ 'shadow-inner': !!selected_titles }"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isTitlesSelectorOpen = true"
>
<template #content>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<UIcon
v-if="!selected_titles"
class="text-2xl opacity-50"
name="i-tabler-brackets-contain"
/>
<NuxtImg
v-else
:src="selected_titles?.opening_url"
/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span :class="!!selected_titles ? 'text-neutral-600' : ''">
{{ selected_titles?.title || '点击选择片头' }}
</span>
<span
v-if="selected_titles?.description"
class="text-2xs"
<UFormField
label="生成线路"
name="gen_server"
>
{{ selected_titles?.description }}
</span>
<USelectMenu
v-model="createCourseState.gen_server"
:items="[
{
label: '主线路',
value: 'main',
},
{
label: '备用线路',
value: 'standby1',
},
]"
value-key="value"
/>
</UFormField>
<UFormField
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<USlider
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormField>
</div>
</div>
</UFormGroup>
<!-- <UFormGroup label="视频片头片尾" name="opening">
<div
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center">
<UIcon class="text-2xl opacity-50" name="i-tabler-brackets-contain"/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span>点击选择</span>
</div>
</div>
</UFormGroup> -->
</div>
</template>
</UAccordion>
</UForm>
<UFormGroup
label="PPT 文件"
required
>
<template #help>
<p class="text-xs text-neutral-400">仅支持 .pptx 格式</p>
</template>
<FileDnD
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
@change="(file) => (selected_file = file)"
/>
</UFormGroup>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
<ModalVideoTitleSelect
:is-open="isTitlesSelectorOpen"
@close="isTitlesSelectorOpen = false"
@select="
(titles) => {
selected_titles = titles as TitlesTemplate
}
"
/>
<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"
>
<UFormGroup
label="生成线路"
name="gen_server"
>
<USelectMenu
v-model="createCourseState.gen_server"
:options="[
{
label: '主线路',
value: 'main',
},
{
label: '备用线路',
value: 'standby1',
},
]"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UFormGroup
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<URange
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormGroup>
</div>
</template>
</UAccordion>
</UForm>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="gray"
label="取消"
size="lg"
variant="ghost"
@click="slide.close()"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
<ModalVideoTitleSelect
:is-open="isTitlesSelectorOpen"
@close="isTitlesSelectorOpen = false"
@select="
(titles) => {
selected_titles = titles as TitlesTemplate
}
"
/>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="neutral"
label="取消"
size="lg"
variant="ghost"
@click="$emit('close')"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
</template>
</USlideover>
</template>

View File

@@ -4,9 +4,7 @@ import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success'])
const slide = useSlideover()
const emit = defineEmits(['success', 'close'])
const toast = useToast()
const loginState = useLoginState()
@@ -100,16 +98,16 @@ const onCreateCourseGreenSubmit = async (
toast.add({
title: '创建成功',
description: '视频已加入生成队列',
color: 'green',
color: 'success',
icon: 'i-tabler-check',
})
emit('success')
slide.close()
emit('close')
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
}
@@ -120,7 +118,7 @@ const onCreateCourseGreenSubmit = async (
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
})
@@ -128,174 +126,174 @@ const onCreateCourseGreenSubmit = async (
</script>
<template>
<USlideover prevent-close>
<UCard
:ui="{
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建绿幕视频
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="slide.close()"
/>
</div>
</template>
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseGreenSubmit"
<USlideover :dismissible="false">
<template #content>
<UCard
:ui="{
body: 'flex-1',
}"
class="flex flex-1 flex-col"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup
label="视频标题"
name="title"
required
>
<UInput
v-model="createCourseState.title"
placeholder="请输入视频标题"
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建绿幕视频
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="emit('close')"
/>
</UFormGroup>
</div>
</div>
</template>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<UFormGroup
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isDigitalSelectorOpen = true"
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseGreenSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormField
label="视频标题"
name="title"
required
>
<UInput
v-model="createCourseState.title"
placeholder="请输入视频标题"
/>
</UFormField>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<UFormField
label="数字人"
name="digital_human_id"
required
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
@click="isDigitalSelectorOpen = true"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</div>
</UFormGroup>
</div>
</UFormField>
</div>
<!-- <UFormGroup label="背景图片" name="bg_img" help="可以上传图片作为视频背景,留空则为绿幕背景">
<!-- <UFormField label="背景图片" name="bg_img" help="可以上传图片作为视频背景,留空则为绿幕背景">
<UInput type="file" accept="image/jpg,image/png" placeholder="选择背景图片" @change="selected_bg_img = $event?.[0] || undefined"/>
</UFormGroup> -->
</UFormField> -->
<UFormGroup
label="驱动内容"
name="content"
required
>
<UTextarea
v-model="createCourseState.content"
:rows="6"
autoresize
placeholder="请输入驱动文本内容"
/>
</UFormGroup>
<UFormField
label="驱动内容"
name="content"
required
>
<UTextarea
v-model="createCourseState.content"
:rows="6"
autoresize
placeholder="请输入驱动文本内容"
/>
</UFormField>
<UFormGroup
label="启用背景合成"
name="bg_img"
help="开启后生成透明通道,可在视频生成完毕后选择自定义背景合成;关闭则使用绿幕背景。"
>
<UToggle v-model="enableBackgroundCompositing" />
</UFormGroup>
<UFormField
label="启用背景合成"
name="bg_img"
help="开启后生成透明通道,可在视频生成完毕后选择自定义背景合成;关闭则使用绿幕背景。"
>
<USwitch v-model="enableBackgroundCompositing" />
</UFormField>
<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"
>
<UFormGroup
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<URange
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormGroup>
</div>
</template>
</UAccordion>
</UForm>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
<UAccordion
:items="[{ label: '高级选项' }]"
color="gray"
label="取消"
size="lg"
variant="ghost"
@click="slide.close()"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
>
<template #content>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<UFormField
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<USlider
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormField>
</div>
</template>
</UAccordion>
</UForm>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="neutral"
label="取消"
size="lg"
variant="ghost"
@click="emit('close')"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
</template>
</USlideover>
</template>

View File

@@ -68,7 +68,7 @@ const getShapeSize = (r: { w: number; h: number }, size: number) => {
:class="[ratio.value === selected && 'bg-sky-200/50 dark:bg-sky-700/50']"
>
<div
class="bg-neutral-300/50 dark:bg-neutral-600/50 text-neutral-600 dark:text-neutral-300 rounded-sm flex justify-center items-center"
class="bg-neutral-300/50 dark:bg-neutral-600/50 text-neutral-600 dark:text-neutral-300 rounded-xs flex justify-center items-center"
:class="[
ratio.value === selected && 'bg-sky-300/50 dark:bg-sky-600/50',
]"

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import type { ChatSession } from '~/typings/llm'
const props = defineProps({
active: {
type: Boolean,
default: false,
},
chatSession: {
type: Object as PropType<ChatSession>,
required: true,
},
})
const emit = defineEmits<{
(e: 'remove', session: ChatSession): void
}>()
const dayjs = useDayjs()
</script>
<template>
<div
class="chat-card group"
:class="{ active: active }"
:title="chatSession.subject"
>
<div class="chat-card-title">
<Icon
v-if="!!chatSession.assistant"
name="i-tabler-masks-theater"
class="text-lg mr-1"
/>
<span class="flex-1 text-ellipsis overflow-x-hidden">
{{
!!chatSession.assistant
? chatSession.assistant.tpl_name
: chatSession.subject
}}
</span>
</div>
<div class="chat-card-meta">
<div>{{ chatSession.messages.length }}条对话</div>
<div>
{{ dayjs(chatSession.create_at * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</div>
</div>
<div
@click.stop="emit('remove', chatSession)"
class="chat-card-remove-btn text-neutral-400 group-hover:opacity-100 md:group-hover:-translate-x-0.5"
>
<Icon name="i-tabler-trash" />
</div>
</div>
</template>
<style lang="scss" scoped>
.chat-card {
@apply flex flex-col gap-2 bg-white dark:bg-neutral-800 px-4 py-3 rounded-lg relative border-2 border-transparent shadow-card;
@apply transition-none duration-150 hover:bg-cyan-300/5;
@apply select-none;
&.active {
@apply border-cyan-500;
}
&-title {
@apply w-[calc(100%-16px)] inline-flex items-center text-sm font-medium text-ellipsis text-nowrap overflow-x-hidden;
}
&-meta {
@apply flex justify-between items-center text-xs text-neutral-400;
}
&-remove-btn {
@apply absolute top-0.5 right-0 md:opacity-0;
@apply transition duration-300 hover:text-red-400;
@apply cursor-pointer;
}
}
</style>

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import type { ChatMessage } from '~/typings/llm'
import MessageResponding from '~/components/Icon/MessageResponding.vue'
const props = defineProps({
message: {
type: Object as PropType<ChatMessage>,
required: true,
},
})
const dayjs = useDayjs()
const message_place_end = computed(() => props.message?.role !== 'assistant')
const message_avatar = computed(() => {
switch (props.message?.role) {
case 'user':
return 'i-fluent-emoji-slightly-smiling-face'
case 'assistant':
return 'i-fluent-emoji-robot'
case 'system':
return 'i-fluent-emoji-receipt'
}
})
const message_background = computed(() => {
if (props.message?.interrupted) {
return 'bg-red-200/50 dark:bg-red-800/20 border-red-300 dark:border-red-500/50!'
}
switch (props.message?.role) {
case 'user':
return 'bg-primary-100 dark:bg-primary-800'
case 'assistant':
case 'system':
return 'bg-neutral-100 dark:bg-neutral-800'
}
})
</script>
<template>
<div
class="chat"
:class="{ 'justify-end': message_place_end }"
>
<div
class="chat-inside"
:class="{ 'items-end': message_place_end }"
>
<div class="chat-inside-avatar">
<Icon
:name="message_avatar"
class="text-lg"
/>
</div>
<div
class="flex flex-col"
:class="{ 'items-end': message_place_end }"
>
<Transition
mode="out-in"
name="message-content-change"
>
<div
class="chat-inside-content relative"
:class="message_background"
:key="message.content"
>
<div v-if="message.content">
<!-- TODO: 生成结果的代码添加复制按钮 -->
<Markdown :source="message.content" />
</div>
<span v-else>
<MessageResponding
class="text-xl text-neutral-500 dark:text-neutral-300 mx-2"
/>
</span>
</div>
</Transition>
<div
v-if="message.preset"
class="chat-inside-extra"
>
预设消息
</div>
<div
v-else-if="message.create_at"
class="chat-inside-extra"
>
{{ dayjs(message.create_at * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.chat {
@apply w-full flex;
&-inside {
@apply w-fit flex flex-col gap-2;
@apply md:max-w-[80%];
&-avatar {
@apply w-8 h-8 flex justify-center items-center rounded-xl;
@apply bg-white border shadow-card;
@apply dark:bg-neutral-800 dark:border-neutral-700;
}
&-content {
@apply px-2 py-2.5 rounded-xl text-sm w-fit;
@apply border dark:border-neutral-700;
}
&-extra {
@apply px-1 text-xs text-neutral-300 dark:text-neutral-700;
}
}
}
.message-content-change-enter-active,
.message-content-change-leave-active {
@apply transition-all duration-300 overflow-hidden;
}
.message-content-change-enter-from {
@apply opacity-0 translate-y-4;
}
</style>

View File

@@ -1,208 +0,0 @@
<script setup lang="ts">
import type { Assistant } from '~/typings/llm'
import { useLazyAsyncData } from '#app'
const loginState = useLoginState()
const props = defineProps({
nonBack: {
type: Boolean,
default: false,
},
})
// noinspection JSUnusedLocalSymbols
const emit = defineEmits({
select: (assistant: Assistant | null) => true,
cancel: () => true,
})
const { data: assistantTemplates, pending: assistantTemplatesPending } =
await useLazyAsyncData(
'App.Assistant_Template.GetList',
() =>
useFetchWrapped<
req.AssistantTemplateList & AuthedRequest,
BaseResponse<PagedData<Assistant>>
>('App.Assistant_Template.GetList', {
user_id: loginState.user.id,
token: loginState.token as string,
page: 1,
perpage: 20,
}),
{
server: false,
}
)
</script>
<template>
<div class="w-full h-full flex flex-col items-center gap-4 relative">
<Transition name="loading-screen">
<div
v-if="assistantTemplatesPending"
class="absolute inset-0 bg-white dark:bg-neutral-900 flex justify-center items-center z-1 text-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
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>
</Transition>
<div class="w-full p-2">
<UButton
v-if="!nonBack"
variant="ghost"
size="xs"
@click="emit('cancel')"
>
<template #leading>
<UIcon name="i-tabler-chevron-left" />
</template>
<span>返回</span>
</UButton>
</div>
<div class="flex flex-col items-center gap-8">
<h1 class="text-lg font-medium flex flex-col items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="2em"
height="2em"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path
d="M13.192 9h6.616a2 2 0 0 1 1.992 2.183l-.567 6.182A4 4 0 0 1 17.25 21h-1.5a4 4 0 0 1-3.983-3.635l-.567-6.182A2 2 0 0 1 13.192 9M15 13h.01M18 13h.01"
/>
<path
d="M15 16.5c1 .667 2 .667 3 0m-9.368-.518A4.037 4.037 0 0 1 8.25 16h-1.5a4 4 0 0 1-3.983-3.635L2.2 6.183A2 2 0 0 1 4.192 4h6.616a2 2 0 0 1 2 2M6 8h.01M9 8h.01"
/>
<path d="M6 12c.764-.51 1.528-.63 2.291-.36" />
</g>
</svg>
<span>选择智能助手</span>
</h1>
<UButton
class="group ring-primary hover:ring-2 transition duration-300"
variant="soft"
size="lg"
:ui="{ rounded-sm: 'rounded-full' }"
@click="emit('select', null)"
>
<span class="-mt-0.5">直接开始</span>
<template #trailing>
<span
class="group-hover:translate-x-1 transition duration-300 ease-out relative w-3 h-full -mt-0.5"
>
<UIcon
name="i-tabler-arrow-right"
class="w-5 h-5 absolute top-auto bottom-auto right-0 opacity-0 group-hover:opacity-100 transition duration-300"
/>
<UIcon
name="i-tabler-chevron-right"
class="w-5 h-5 absolute top-auto bottom-auto right-0 -mr-[3.5px] group-hover:opacity-0 transition duration-300"
/>
</span>
</template>
</UButton>
</div>
<div
class="w-full md:w-3/4 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 overflow-y-auto p-4 md:p-8"
>
<div
v-for="assistant in assistantTemplates?.data.items || []"
:key="assistant.id"
class="assistant-item select-none"
@click="emit('select', assistant)"
>
<div class="flex flex-col gap-1">
<div class="text-base font-medium">{{ assistant.tpl_name }}</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400">
{{ assistant.des }}
</div>
</div>
</div>
</div>
</div>
</template>
<!--suppress CssUnusedSymbol -->
<style scoped>
.loading-screen-leave-active {
@apply transition duration-300;
}
.loading-screen-leave-to {
@apply opacity-0;
}
.assistant-item {
@apply w-full bg-white dark:bg-neutral-800 rounded-lg shadow-xs ring-primary ring-offset-2 dark:ring-offset-0 hover:ring-2 transition;
@apply flex items-center gap-4 px-4 py-2 cursor-pointer border dark:border-neutral-700 hover:border-transparent;
}
</style>

View File

@@ -1,51 +0,0 @@
<script lang="ts" setup>
const props = defineProps({
label: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
comment: {
type: String,
default: '',
},
})
</script>
<template>
<div
class="bg-neutral-50 dark:bg-neutral-900 px-1.5 py-1 rounded-sm flex flex-col gap-1 shadow-sm"
>
<div class="flex items-center gap-1 text-sm">
<UIcon
v-if="icon"
:name="icon"
class="text-base inline-block"
/>
<div
class="flex-1 flex items-center truncate whitespace-nowrap overflow-hidden"
>
<span>{{ label }}</span>
<UTooltip
v-if="comment"
:popper="{ arrow: true, placement: 'right' }"
:text="comment"
>
<UIcon
class="text-base"
name="i-tabler-help"
/>
</UTooltip>
</div>
<slot name="actions" />
</div>
<div class="flex flex-col gap-2">
<slot />
</div>
</div>
</template>
<style scoped></style>

View File

@@ -1,303 +0,0 @@
<script setup lang="ts">
import type { ResultBlockMeta } from '~/components/aigc/drawing/index'
import type { PropType } from 'vue'
import dayjs from 'dayjs'
import { get } from 'idb-keyval'
const props = defineProps({
icon: {
type: String,
default: 'i-tabler-photo-filled',
},
prompt: {
type: String,
},
fid: {
type: String,
required: true,
},
images: {
type: Array,
},
meta: {
type: Object as PropType<ResultBlockMeta>,
},
})
const emit = defineEmits(['use-reference'])
const toast = useToast()
const expand_prompt = ref(false)
const show_meta = ref(true)
const cachedImages = ref<string[]>([])
const cachedImagesInterval = ref<NodeJS.Timeout | null>(null)
onMounted(async () => {
cachedImagesInterval.value = setInterval(async () => {
const res = ((await get(props.fid)) as string[]) || []
if (res.length === cachedImages.value.length) return
cachedImages.value = res
}, 200)
})
onUnmounted(() => {
if (cachedImagesInterval.value) {
clearInterval(cachedImagesInterval.value)
}
})
const handle_download = (url: string) => {
const a = document.createElement('a')
a.href = url
a.download = `xsh_ai_drawing-${dayjs(props.meta?.datetime! * 1000).format('YYYY-MM-DD-HH-mm-ss')}.png`
a.click()
}
const handle_use_reference = async (blob_url: string) => {
fetch(blob_url)
.then((res) => res.blob())
.then((blob) => {
const file = new File(
[blob],
`xsh_drawing-${props.meta?.datetime! * 1000}.png`,
{ type: 'image/png' }
)
emit('use-reference', file)
})
.catch(() => {
toast.add({
title: '转换失败',
description: '无法获取图片数据',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
}
const copyToClipboard = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
toast.add({
title: '复制成功',
description: '已将内容复制到剪贴板',
color: 'primary',
icon: 'i-tabler-copy',
})
})
.catch(() => {
toast.add({
title: '复制失败',
description: '无法复制到剪贴板',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
}
</script>
<template>
<div class="w-full">
<div class="flex items-center gap-1">
<UIcon :name="icon" />
<h1 class="text-sm font-semibold">
{{ meta.type || 'AI 智能绘图' }}
</h1>
<UDivider
class="flex-1"
size="sm"
/>
<UButton
color="black"
size="xs"
icon="i-tabler-info-circle"
:variant="show_meta ? 'solid' : 'ghost'"
:disabled="!meta"
@click="show_meta = !show_meta"
></UButton>
<slot name="header-right" />
</div>
<div
v-if="prompt"
class="flex items-start gap-2 mt-1 mb-2"
>
<UIcon
name="i-tabler-article"
class="mt-0.5"
/>
<p
class="text-sm flex-1 text-ellipsis cursor-pointer"
:class="{
'line-clamp-1': !expand_prompt,
'line-clamp-none': expand_prompt,
}"
@click="expand_prompt = !expand_prompt"
>
{{ prompt }}
</p>
<UButton
color="gray"
size="xs"
icon="i-tabler-copy"
variant="ghost"
class="-mt-1"
@click="copyToClipboard(prompt)"
></UButton>
</div>
<div
v-if="cachedImages.length > 0"
class="flex items-center overflow-x-auto h-64 gap-2 pb-2 snap-x"
>
<div
class="h-full aspect-auto relative rounded-lg shadow-md overflow-hidden group"
v-for="(url, i) in cachedImages"
:key="`${fid}-${i}`"
>
<div
class="absolute inset-0 bg-linear-to-t from-neutral-800/40 to-transparent w-full h-full flex items-end scale-105 opacity-0 group-hover:scale-100 group-hover:opacity-100 transition"
>
<div class="w-full flex justify-end gap-1 p-1">
<UTooltip text="以此图为参考创作">
<UButton
color="indigo"
variant="soft"
size="2xs"
icon="i-tabler-copy"
square
@click="handle_use_reference(url)"
></UButton>
</UTooltip>
<UTooltip text="下载">
<UButton
color="indigo"
variant="soft"
size="2xs"
icon="i-tabler-download"
square
@click="handle_download(url)"
></UButton>
</UTooltip>
</div>
</div>
<img
class="result-image"
:src="useBlobUrlFromB64(url)"
alt="AI Generated"
/>
</div>
</div>
<div
v-else
class="h-64 aspect-3/4 mb-4 rounded-lg placeholder-gradient flex justify-center items-center"
>
<UIcon
name="i-svg-spinners-tadpole"
class="text-3xl"
/>
</div>
<Transition
v-if="meta"
name="meta"
>
<div
v-if="show_meta"
class="w-full flex items-center gap-2 flex-wrap whitespace-nowrap pb-2 mt-2"
>
<UBadge
v-if="meta.modal"
color="black"
variant="solid"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-box-seam"
/>
{{ meta.modal }}
</UBadge>
<UBadge
v-if="meta.style"
color="green"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-christmas-tree"
/>
{{ meta.style }}
</UBadge>
<UBadge
v-if="meta.cost"
color="amber"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-solar-fire-bold"
/>
{{ meta.cost }}
</UBadge>
<UBadge
v-if="meta.ratio"
color="indigo"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-aspect-ratio"
/>
{{ meta.ratio }}
</UBadge>
<UBadge
v-if="meta.id"
color="indigo"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-number"
/>
{{ meta.id }}
</UBadge>
<UBadge
v-if="meta.datetime"
color="indigo"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-calendar-month"
/>
{{ dayjs(meta.datetime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</UBadge>
</div>
</Transition>
</div>
</template>
<style scoped>
.meta-enter-active,
.meta-leave-active {
@apply transition duration-300;
}
.meta-enter-from,
.meta-leave-to {
@apply opacity-0 -translate-y-2;
}
.result-image {
@apply snap-start;
@apply w-full h-full object-cover;
}
.placeholder-gradient {
@apply animate-pulse bg-linear-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800;
}
</style>

View File

@@ -1,9 +0,0 @@
export declare interface ResultBlockMeta {
modal?: string
cost?: string
ratio?: string
id?: string
style?: string
datetime?: number
type?: string
}

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>

View File

@@ -45,9 +45,9 @@ const activeClass = computed(() => {
</div>
<UBadge
v-if="admin"
color="amber"
color="warning"
label="OP"
size="xs"
size="sm"
variant="subtle"
/>
</NuxtLink>

View File

@@ -98,7 +98,7 @@ const handleInput = (e: any) => {
</p>
<div class="relative">
<input
class="relative w-full flex items-center gap-2.5 p-2 pr-2 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 outline-hidden placeholder-neutral-400 dark:placeholder-neutral-500 shadow-xs"
class="relative w-full flex items-center gap-2.5 p-2 pr-2 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-neutral-200/50 dark:focus:ring-neutral-800/50 outline-hidden placeholder-neutral-400 dark:placeholder-neutral-500 shadow-xs"
:class="{
'border-red-500!': isError,
'bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600':

View File

@@ -97,7 +97,7 @@ onMounted(() => {
ref="selectWrapperRef"
>
<button
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 shadow-xs"
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-neutral-200/50 dark:focus:ring-neutral-800/50 shadow-xs"
:class="{
'cursor-not-allowed bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600':
disabled,

View File

@@ -116,7 +116,7 @@ onMounted(() => {
</p>
<div class="relative">
<textarea
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden overflow-y-auto border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 outline-hidden placeholder-neutral-400 dark:placeholder-neutral-500 shadow-xs"
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden overflow-y-auto border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-neutral-200/50 dark:focus:ring-neutral-800/50 outline-hidden placeholder-neutral-400 dark:placeholder-neutral-500 shadow-xs"
:rows="minRows || rows"
ref="textAreaRef"
:class="{

View File

@@ -107,7 +107,7 @@ watch(
@click="handleCheck"
>
<span
class="aspect-1/1 translate-x-0 transition ease-in-out bg-white dark:bg-black rounded-md shadow-sm duration-300 group-active:scale-90"
class="aspect-1/1 translate-x-0 transition ease-in-out bg-white dark:bg-black rounded-md shadow-xs duration-300 group-active:scale-90"
:class="{
'shadow-lg!': checked,
'group-active:translate-x-3 group-active:duration-500': !checked,