Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
2024-10-22 17:10:37 +08:00
37 changed files with 13577 additions and 10136 deletions

View File

@@ -1,27 +1,27 @@
export default defineAppConfig({ export default defineAppConfig({
ui: { ui: {
primary: 'indigo', primary: 'indigo',
gray: 'neutral', gray: 'neutral',
strategy: 'merge', strategy: 'merge',
button: { button: {
icon: { icon: {
loading: 'animate-none', loading: 'animate-none',
}, },
default: { default: {
loadingIcon: 'i-svg-spinners-180-ring-with-bg' loadingIcon: 'i-svg-spinners-180-ring-with-bg',
} },
}, },
notifications: { notifications: {
position: 'top-0 bottom-auto' position: 'top-0 bottom-auto',
}, },
horizontalNavigation: { horizontalNavigation: {
container: 'gap-2', container: 'gap-2',
base: 'px-3 py-4', base: 'px-3 py-4',
after: 'after:hidden', after: 'after:hidden',
active: 'before:bg-neutral-600 hover:before:bg-neutral-600 dark:before:bg-neutral-800 dark:hover:before:bg-neutral-800 text-neutral-50 dark:text-neutral-300', active: 'before:bg-neutral-600 hover:before:bg-neutral-600 dark:before:bg-neutral-800 dark:hover:before:bg-neutral-800 text-neutral-50 dark:text-neutral-300',
icon: { icon: {
active: 'text-neutral-50 dark:text-neutral-300', active: 'text-neutral-50 dark:text-neutral-300',
} },
}, },
} },
}) })

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ModalAuthentication from '~/components/ModalAuthentication.vue'; import ModalAuthentication from '~/components/ModalAuthentication.vue'
const toast = useToast() const toast = useToast()
const router = useRouter() const router = useRouter()
@@ -8,7 +8,7 @@ const loginState = useLoginState()
useHead({ useHead({
titleTemplate(title) { titleTemplate(title) {
return title ? `${title} - 眩生花 AI 助手` : '眩生花 AI 助手' return title ? `${ title } - 眩生花 AI 助手` : '眩生花 AI 助手'
}, },
}) })
@@ -33,13 +33,14 @@ onMounted(() => {
<template> <template>
<div> <div>
<NuxtLoadingIndicator /> <NuxtLoadingIndicator/>
<NuxtLayout> <NuxtLayout>
<NuxtPage/> <NuxtPage/>
</NuxtLayout> </NuxtLayout>
<UModals/> <UModals/>
<USlideovers/>
<UNotifications/> <UNotifications/>
</div> </div>
</template> </template>

View File

@@ -12,23 +12,34 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
bubbleColor: {
type: String,
default: 'primary-500',
},
}) })
</script> </script>
<template> <template>
<div class="relative font-sans select-none"> <div class="relative font-sans select-none flex justify-between items-center">
<h1 <div>
v-if="subtitle" <h1
class="text-base text-neutral-300 italic tracking-wide font-black leading-none" v-if="subtitle"
>{{ subtitle }}</h1> class="text-base text-neutral-300 dark:text-neutral-600 italic tracking-wide font-black leading-none"
>{{ subtitle }}</h1>
<h1 class="text-xl font-bold text-neutral-700 leading-none relative z-[1]"> <h1 class="text-xl font-bold text-neutral-700 dark:text-neutral-300 leading-none relative z-[1]">
{{ title }} {{ title }}
</h1> </h1>
</div>
<div class="flex gap-2.5">
<slot name="action"/>
</div>
<div <div
v-if="bubble" v-if="bubble"
class="absolute -left-1.5 -bottom-1.5 w-4 h-4 rounded-full bg-primary-500/50 z-[0]" :class="`bg-${bubbleColor}/50`"
class="absolute -left-1.5 -bottom-1.5 w-4 h-4 rounded-full z-[0]"
></div> ></div>
</div> </div>
</template> </template>

View File

@@ -4,13 +4,26 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
lineGradientFrom: {
type: String,
default: 'primary',
},
lineGradientTo: {
type: String,
default: 'primary',
},
}) })
</script> </script>
<template> <template>
<div <div
class="bg-gradient-to-r from-primary-500/50 to-primary-300/50 rounded-full my-4" :class="{
:class="{'w-full h-[1px]': !vertical, 'w-[1px] h-full': vertical}" 'w-full h-[1px]': !vertical,
'w-[1px] h-full': vertical,
[`from-${lineGradientFrom}-500/50`]: true,
[`to-${lineGradientTo}-300/50`]: true,
}"
class="bg-gradient-to-r rounded-full my-4"
></div> ></div>
</template> </template>

View File

@@ -8,6 +8,10 @@ defineProps({
type: String, type: String,
default: '', default: '',
}, },
needAdmin: {
type: Boolean,
default: false,
},
}) })
const modal = useModal() const modal = useModal()
@@ -16,19 +20,24 @@ const modal = useModal()
<template> <template>
<ClientOnly> <ClientOnly>
<div v-if="!loginState.is_logged_in" <div v-if="!loginState.is_logged_in"
class="w-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900"> class="w-full flex flex-col justify-center items-center gap-2 py-40">
<Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/> <Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p> <p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton <UButton
class="mt-2 font-bold" class="mt-2 font-bold"
color="black" color="black"
variant="solid" size="xs"
size="xs" variant="solid"
@click="modal.open(ModalAuthentication)" @click="modal.open(ModalAuthentication)"
> >
登录 登录
</UButton> </UButton>
</div> </div>
<div v-else-if="needAdmin && loginState.user.auth_code !== 2"
class="w-full flex flex-col justify-center items-center gap-2 py-40">
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="tabler:hand-stop"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">账号没有权限</p>
</div>
<div :class="contentClass" v-else> <div :class="contentClass" v-else>
<slot/> <slot/>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import {Label, PinInputInput, PinInputRoot} from 'radix-vue' import { Label, PinInputInput, PinInputRoot } from 'radix-vue'
import {useFetchWrapped} from '~/composables/useFetchWrapped'; import { useFetchWrapped } from '~/composables/useFetchWrapped'
const toast = useToast() const toast = useToast()
const modal = useModal() const modal = useModal()
@@ -199,7 +199,6 @@ const handle_sms_verify = (e: string[]) => {
<UTabs :items="items" class="w-full"> <UTabs :items="items" class="w-full">
<template #default="{ item, index, selected }"> <template #default="{ item, index, selected }">
<div class="flex items-center gap-2 relative truncate"> <div class="flex items-center gap-2 relative truncate">
<UIcon :name="item.icon" class="w-4 h-4 flex-shrink-0"/>
<span class="truncate">{{ item.label }}</span> <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"/> <span v-if="selected" class="absolute -right-4 w-2 h-2 rounded-full bg-primary-500 dark:bg-primary-400"/>
</div> </div>

View File

@@ -0,0 +1,244 @@
<script lang="ts" setup>
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const props = defineProps({
isOpen: {
type: Boolean,
required: false,
},
multiple: {
type: Boolean,
default: false,
},
disabledDigitalHumanIds: {
type: Array,
default: () => [],
},
defaultTab: {
type: String as PropType<'user' | 'system'>,
default: 'user',
},
})
const emit = defineEmits({
close: () => true,
select: (digitalHumans: DigitalHumanItem | DigitalHumanItem[]) => digitalHumans,
})
const loginState = useLoginState()
const modal = useModal()
const toast = useToast()
const page = ref(1)
const selectedDigitalHumans = ref<DigitalHumanItem[]>([])
const handleSelectClick = (item: DigitalHumanItem) => {
// 如果点击的项目已经在已选列表中,则移除;否则添加
if (selectedDigitalHumans.value.includes(item)) {
selectedDigitalHumans.value = selectedDigitalHumans.value.filter(d => d !== item)
} else {
selectedDigitalHumans.value = props.multiple ? [...selectedDigitalHumans.value, item] : [item]
}
}
const handleClose = () => {
selectedDigitalHumans.value = []
if (props.isOpen) {
emit('close')
} else {
modal.close()
}
}
const handleSubmit = () => {
if (selectedDigitalHumans.value.length === 0) {
toast.add({
title: '请选择数字人',
description: '请至少选择一个数字人',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
}
emit('select', props.multiple ? selectedDigitalHumans.value : selectedDigitalHumans.value[0])
handleClose()
setTimeout(() => {
page.value = 1
}, 300)
}
const tabItems = [{
key: 'user',
label: '我的数字人',
icon: 'i-tabler-user',
}]
const tabIndex = ref(0)
watch(tabIndex, () => {
page.value = 1
})
const {
data: userDigitalList,
} = useAsyncData(
'user-digital-human',
() => useFetchWrapped<req.gen.DigitalHumanList & AuthedRequest, BaseResponse<PagedData<DigitalHumanItem>>>(
'App.User_UserDigital.GetList',
{
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 15,
},
),
{
watch: [page],
},
)
const {
data: systemDigitalList,
} = useAsyncData(
'system-digital-human',
() => useFetchWrapped<req.gen.DigitalHumanList & AuthedRequest, BaseResponse<PagedData<DigitalHumanItem>>>(
'App.Digital_Human.GetList',
{
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 15,
},
),
{
watch: [page],
},
)
onMounted(() => {
if (loginState.user.auth_code === 2) {
tabItems.push({
key: 'system',
label: '系统数字人',
icon: 'i-tabler-user-star',
})
nextTick(() => {
tabIndex.value = tabItems.findIndex(i => i.key === props.defaultTab)
console.log('tabIndex', tabIndex.value)
})
}
})
</script>
<template>
<UModal
:model-value="isOpen"
:ui="{ width: '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"
>
<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"
>
<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
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute top-1 right-1 text-lg text-red-500"
name="tabler:user-off"
/>
</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">
{{ d.name }}
</span>
<span class="text-xs text-neutral-300 dark:text-neutral-500 font-medium">
ID:{{ d.digital_human_id || d.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<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>
</UModal>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,246 @@
<script lang="ts" setup>
import FileDnD from '~/components/uni/FileDnD/index.vue'
import { type InferType, number, object, string } from 'yup'
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 toast = useToast()
const loginState = useLoginState()
const creationForm = ref<HTMLFormElement>()
const creationPending = ref(false)
const isDigitalSelectorOpen = ref(false)
const createCourseSchema = object({
task_title: string().trim().min(4, '标题必须大于4个字符').max(20, '标题不能超过20个字符').required('请输入微课标题'),
digital_human_id: number().not([0], '请选择数字人'),
gen_server: string().required(),
speed: number().default(1.0).min(0.5).max(1.5).required(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
task_title: undefined,
digital_human_id: 0,
gen_server: 'main',
speed: 1.0,
})
const selected_file = ref<File[] | null>(null)
const selected_digital_human = ref<DigitalHumanItem | null>(null)
watchEffect(() => {
if (selected_digital_human.value) {
createCourseState.digital_human_id = selected_digital_human.value.model_id || selected_digital_human.value.id!
}
})
const onCreateCourseSubmit = async (event: FormSubmitEvent<CreateCourseSchema>) => {
if (!selected_file.value) {
toast.add({
title: '未选择文件',
description: '请先选择 PPTX 文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
creationPending.value = true
// upload PPTX file
useFileGo(selected_file.value[0]).then(url => {
useFetchWrapped<req.gen.CourseGenCreate & AuthedRequest, BaseResponse<resp.gen.CourseGenCreate>>('App.Digital_Convert.Create', {
token: loginState.token!,
user_id: loginState.user.id,
task_title: event.data.task_title,
gen_server: event.data.gen_server as 'main' | 'standby1',
speed: 2 - event.data.speed,
ppt_url: url,
digital_human_id: event.data.digital_human_id,
custom_video: '[]',
opening_url: '',
ending_url: '',
}).then(res => {
if (res.data.record_status === 1) {
toast.add({
title: '创建成功',
description: '已加入生成队列',
color: 'green',
icon: 'i-tabler-check',
})
emit('success')
slide.close()
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
}).catch(e => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
})
}
</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"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup label="微课标题" name="task_title" required>
<UInput v-model="createCourseState.task_title" placeholder="请输入微课标题"/>
</UFormGroup>
</div>
<div class="grid 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"
>
<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">
<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>
<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>
<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)
}"
/>
</USlideover>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,199 @@
<script lang="ts" setup>
import { type InferType, number, object, string } from 'yup'
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 toast = useToast()
const loginState = useLoginState()
const creationForm = ref<HTMLFormElement>()
const creationPending = ref(false)
const isDigitalSelectorOpen = ref(false)
const createCourseSchema = object({
title: string().trim().min(4, '标题必须大于4个字符').max(20, '标题不能超过20个字符').required('请输入视频标题'),
content: string().trim().min(4, '内容必须大于4个字符').max(1000, '内容不能超过1000个字符').required('请输入驱动文本内容'),
digital_human_id: number().not([0], '请选择数字人'),
speed: number().default(1.0).min(0.5).max(1.5).required(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
title: undefined,
content: undefined,
digital_human_id: 0,
speed: 1.0,
})
const selected_digital_human = ref<DigitalHumanItem | null>(null)
watchEffect(() => {
if (selected_digital_human.value) {
createCourseState.digital_human_id = selected_digital_human.value.model_id || selected_digital_human.value.id!
}
})
const onCreateCourseGreenSubmit = async (event: FormSubmitEvent<CreateCourseSchema>) => {
creationPending.value = true
useFetchWrapped<req.gen.GBVideoCreate & AuthedRequest, BaseResponse<resp.gen.GBVideoCreate>>('App.Digital_VideoTask.Create', {
token: loginState.token!,
user_id: loginState.user.id,
title: event.data.title,
content: event.data.content,
digital_human_id: event.data.digital_human_id,
speed: 2 - event.data.speed,
device_id: 'XSHAssistant Web',
}).then(res => {
if (!!res.data.task_id) {
toast.add({
title: '创建成功',
description: '视频已加入生成队列',
color: 'green',
icon: 'i-tabler-check',
})
emit('success')
slide.close()
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
}).catch(e => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
}
</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"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup label="视频标题" name="title" required>
<UInput v-model="createCourseState.title" placeholder="请输入视频标题"/>
</UFormGroup>
</div>
<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"
>
<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>
</div>
<UFormGroup label="驱动内容" name="content" required>
<!-- <template #help>-->
<!-- <p class="text-xs text-neutral-400">-->
<!-- 仅支持 .pptx 格式-->
<!-- </p>-->
<!-- </template>-->
<UTextarea v-model="createCourseState.content" :rows="6" autoresize placeholder="请输入驱动文本内容"/>
</UFormGroup>
<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
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)
}"
/>
</USlideover>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
const props = defineProps({
icon: {
type: String,
default: 'i-tabler-photo-filled',
},
label: {
type: String,
required: true,
},
to: {
type: String,
required: true,
},
admin: {
type: Boolean,
default: false,
},
hide: {
type: Boolean,
default: false,
},
})
const route = useRoute()
const active = computed(() => {
return route.path === props.to
})
const activeClass = computed(() => {
return props.admin ? 'bg-amber-500 text-white' : 'bg-primary text-white'
})
</script>
<template>
<NuxtLink
v-if="!hide"
:class="{
[activeClass]: active,
'hover:bg-neutral-200 dark:hover:bg-neutral-800': !active,
}"
:to="to"
class="px-4 py-3 flex justify-between items-center rounded-lg transition cursor-pointer"
>
<div class="flex items-center gap-2">
<Icon :name="icon" class="text-xl inline"/>
<h1 class="flex-1 text-[14px] font-medium line-clamp-1">
{{ label }}
</h1>
</div>
<UBadge v-if="admin" color="amber" label="OP" size="xs" variant="subtle"/>
</NuxtLink>
</template>
<style scoped>
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type {PropType} from 'vue' import type { PropType } from 'vue'
import type {ChatSession} from '~/typings/llm' import type { ChatSession } from '~/typings/llm'
const props = defineProps({ const props = defineProps({
active: { active: {
@@ -29,10 +29,10 @@ const dayjs = useDayjs()
<Icon <Icon
v-if="!!chatSession.assistant" v-if="!!chatSession.assistant"
name="i-tabler-masks-theater" name="i-tabler-masks-theater"
class="text-lg -mt-1 mr-1" class="text-lg mr-1 "
/> />
<span> <span class="flex-1 text-ellipsis overflow-x-hidden">
{{ !!chatSession.assistant ? chatSession.assistant.tpl_name : chatSession.subject }} {{ !!chatSession.assistant ? chatSession.assistant.tpl_name : chatSession.subject }}啊塞啊塞啊塞啊塞啊塞啊塞
</span> </span>
</div> </div>
<div class="chat-card-meta"> <div class="chat-card-meta">
@@ -43,7 +43,7 @@ const dayjs = useDayjs()
@click.stop="emit('remove', chatSession)" @click.stop="emit('remove', chatSession)"
class="chat-card-remove-btn text-neutral-400 group-hover:opacity-100 md:group-hover:-translate-x-0.5" class="chat-card-remove-btn text-neutral-400 group-hover:opacity-100 md:group-hover:-translate-x-0.5"
> >
<UIcon name="i-tabler-trash"/> <Icon name="i-tabler-trash"/>
</div> </div>
</div> </div>
</template> </template>
@@ -59,7 +59,7 @@ const dayjs = useDayjs()
} }
&-title { &-title {
@apply w-[calc(100%-16px)] text-sm font-medium text-ellipsis text-nowrap overflow-x-hidden; @apply w-[calc(100%-16px)] inline-flex items-center text-sm font-medium text-ellipsis text-nowrap overflow-x-hidden;
} }
&-meta { &-meta {

View File

@@ -1,308 +0,0 @@
<script lang="ts" setup>
import CGTaskCard from '~/components/aigc/course-generate/CGTaskCard.vue'
import FileDnD from '~/components/uni/FileDnD/index.vue'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
import { type InferType, number, object, string } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
import ModalAuthentication from '~/components/ModalAuthentication.vue'
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const isCreateCourseModalOpen = ref(false)
const creationPending = ref(false)
const deletePending = ref(false)
const {
data: courseList,
pending: courseListPending,
refresh: refreshCourseList,
} = useAsyncData(
() => useFetchWrapped<
req.gen.CourseGenList & AuthedRequest,
BaseResponse<PagedData<resp.gen.CourseGenItem>>
>('App.Digital_Convert.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: 1,
perpage: 10,
}), {
transform: res => res?.data.items || [],
},
)
const onCreateCourseClick = () => {
isCreateCourseModalOpen.value = true
}
const createCourseSchema = object({
task_title: string().trim().min(4, '标题必须大于4个字符').max(20, '标题不能超过20个字符').required('请输入微课标题'),
gen_server: string().required(),
speed: number().default(1.0).min(0.5).max(1.5).required(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
task_title: undefined,
gen_server: 'main',
speed: 1.0,
})
const selected_file = ref<File[] | null>(null)
const onCreateCourseSubmit = async (event: FormSubmitEvent<CreateCourseSchema>) => {
console.log(event.data)
if (!selected_file.value) {
toast.add({
title: '未选择文件',
description: '请先选择 PPTX 文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
creationPending.value = true
// upload PPTX file
useFileGo(selected_file.value[0]).then(url => {
useFetchWrapped<req.gen.CourseGenCreate & AuthedRequest, BaseResponse<resp.gen.CourseGenCreate>>('App.Digital_Convert.Create', {
token: loginState.token!,
user_id: loginState.user.id,
task_title: event.data.task_title,
gen_server: event.data.gen_server as 'main' | 'standby1',
speed: 2 - event.data.speed,
ppt_url: url,
digital_human_id: 40696,
custom_video: '[]',
opening_url: '',
ending_url: '',
}).then(res => {
if (res.data.record_status === 1) {
toast.add({
title: '创建成功',
description: '微课视频已开始生成',
color: 'green',
icon: 'i-tabler-check',
})
refreshCourseList()
isCreateCourseModalOpen.value = false
} else {
toast.add({
title: '创建失败',
description: '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
}).catch(e => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
})
}
const onCourseDelete = (task_id: string) => {
if (!task_id) return
deletePending.value = true
useFetchWrapped<
req.gen.CourseGenDelete & AuthedRequest,
BaseResponse<resp.gen.CourseGenDelete>
>('App.Digital_Convert.Delete', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
task_id,
}).then(res => {
if (res.ret === 200) {
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
}).finally(() => {
deletePending.value = false
refreshCourseList()
})
}
onMounted(() => {
const i = setInterval(refreshCourseList, 1000 * 5)
onBeforeUnmount(() => clearInterval(i))
})
</script>
<template>
<div class="font-sans h-full">
<div class="p-4 border-b dark:border-neutral-700">
<UButton
:trailing="false"
color="primary"
icon="i-tabler-plus"
label="新建微课"
size="md"
variant="solid"
@click="() => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
onCreateCourseClick()
}"
/>
</div>
<Transition name="loading-screen">
<div v-if="!loginState.is_logged_in" class="w-full h-full">
<div class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton class="mt-2 font-bold" color="black" variant="solid" size="xs"
@click="modal.open(ModalAuthentication)">
登录
</UButton>
</div>
</div>
<div v-else-if="courseList?.length === 0"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-photo-hexagon" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
没有记录
</p>
</div>
<div v-else class="p-4">
<div class="relative grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<TransitionGroup name="card">
<CGTaskCard
v-for="(course, index) in courseList"
:key="course.task_id || 'unknown' + index"
:course="course"
@delete="task_id => onCourseDelete(task_id)"
/>
</TransitionGroup>
</div>
</div>
</Transition>
<UModal
v-model="isCreateCourseModalOpen"
prevent-close
>
<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="isCreateCourseModalOpen = false"
/>
</div>
</template>
<UForm
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup label="微课标题" name="task_title">
<UInput v-model="createCourseState.task_title" placeholder="请输入微课标题"/>
</UFormGroup>
<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>
</div>
<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>
<UFormGroup label="PPT 文件">
<FileDnD
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
@change="file => selected_file = file"
/>
</UFormGroup>
<div class="flex justify-end space-x-4 pt-4">
<UButton
color="gray"
label="取消"
variant="ghost"
@click="isCreateCourseModalOpen = false"
/>
<UButton
color="primary"
label="提交"
type="submit"
variant="solid"
:loading="creationPending"
/>
</div>
</UForm>
</UCard>
</UModal>
</div>
</template>
<style scoped>
.loading-screen-leave-active {
@apply transition duration-300;
}
.loading-screen-leave-to {
@apply opacity-0;
}
.card-enter-active, .card-leave-active {
transition: opacity 0.5s;
}
.card-enter, .card-leave-to {
opacity: 0;
}
.card-enter-to, .card-leave {
opacity: 1;
}
</style>

View File

@@ -1,13 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<div>
绿幕视频生成
</div>
</template>
<style scoped>
</style>

View File

@@ -3,7 +3,7 @@ import type { PropType } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useDownload } from '~/composables/useDownload' import { useDownload } from '~/composables/useDownload'
import gsap from 'gsap' import gsap from 'gsap'
import SRTEditor from '~/components/aigc/course-generate/SRTEditor.vue' import SRTEditor from '~/components/aigc/generation/SRTEditor.vue'
const toast = useToast() const toast = useToast()
const { metaSymbol } = useShortcuts() const { metaSymbol } = useShortcuts()
@@ -37,9 +37,9 @@ defineShortcuts({
}, },
}, },
'meta_s': { 'meta_s': {
handler: () => { handler: async () => {
if (isDropdownOpen.value && isDownloadable.value) { if (isDropdownOpen.value && isDownloadable.value) {
startDownload(props.course.subtitle_url, `眩生花微课_${ props.course.title }_${ props.course.task_id }.srt`) await startDownload(await fetchCourseSubtitleUrl(props.course), `眩生花微课_${ props.course.title }_${ props.course.task_id }.srt`)
} }
}, },
}, },
@@ -181,9 +181,9 @@ const copyTaskId = (extraMessage?: string) => {
<div class="flex-1 overflow-hidden pt-1"> <div class="flex-1 overflow-hidden pt-1">
<h1 <h1
:title="course.title" :title="course.title"
class="text-sm font-medium overflow-hidden text-ellipsis text-nowrap" class="inline-flex items-center text-sm font-medium overflow-hidden text-ellipsis text-nowrap"
> >
<Icon class="-mt-0.5 -ml-0.5 text-base" name="i-tabler-book-2"/> <Icon class="text-base" name="i-tabler-book-2"/>
<span class="pl-0.5">{{ course.title }}</span> <span class="pl-0.5">{{ course.title }}</span>
</h1> </h1>
<p class="text-xs pt-0.5 text-neutral-400 space-x-2"> <p class="text-xs pt-0.5 text-neutral-400 space-x-2">
@@ -220,7 +220,7 @@ const copyTaskId = (extraMessage?: string) => {
disabled: !isDownloadable, disabled: !isDownloadable,
click: () => isPreviewModalOpen = true, click: () => isPreviewModalOpen = true,
}, { }, {
label: '查看字幕', label: '编辑字幕',
icon: 'i-solar-subtitles-linear', icon: 'i-solar-subtitles-linear',
shortcuts: [metaSymbol, 'D'], shortcuts: [metaSymbol, 'D'],
disabled: !isDownloadable, disabled: !isDownloadable,
@@ -233,8 +233,8 @@ const copyTaskId = (extraMessage?: string) => {
icon: 'i-tabler-file-download', icon: 'i-tabler-file-download',
shortcuts: [metaSymbol, 'S'], shortcuts: [metaSymbol, 'S'],
disabled: !isDownloadable, disabled: !isDownloadable,
click: () => { click: async () => {
startDownload(course.subtitle_url, `眩生花微课_${ props.course.title }_${ props.course.task_id }.srt`) await startDownload(await fetchCourseSubtitleUrl(course), `眩生花微课_${ props.course.title }_${ props.course.task_id }.srt`)
} }
}], [{ }], [{
label: '删除记录', label: '删除记录',

View File

@@ -0,0 +1,188 @@
<script lang="ts" setup>
import type { PropType } from 'vue'
const props = defineProps({
video: {
type: Object as PropType<GBVideoItem>,
required: true,
},
})
const emit = defineEmits({
delete: (video: GBVideoItem) => video,
})
const dayjs = useDayjs()
const toast = useToast()
const isFullContentOpen = ref(false)
const downloadingState = reactive({
subtitle: 0,
video: 0,
})
const startDownload = (url: string, filename: string) => {
if (url.endsWith('.ass')) {
downloadingState.subtitle = 0
} else {
downloadingState.video = 0
}
const {
download,
progressEmitter,
} = useDownload(url, filename)
progressEmitter.on('progress', progress => {
if (url.endsWith('.ass')) {
downloadingState.subtitle = progress
} else {
downloadingState.video = progress
}
console.log(downloadingState)
})
progressEmitter.on('done', () => {
if (url.endsWith('.ass')) {
downloadingState.subtitle = 100
} else {
downloadingState.video = 100
}
toast.add({
title: '下载完成',
description: '资源下载已完成',
color: 'green',
icon: 'i-tabler-check',
})
})
progressEmitter.on('error', err => {
if (url.endsWith('.ass')) {
downloadingState.subtitle = 0
} else {
downloadingState.video = 0
}
toast.add({
title: '下载失败',
description: err.message || '下载失败,未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
download()
}
const onClick = () => {
console.log('click delete')
}
</script>
<template>
<div
class="w-full flex gap-2 rounded-xl border border-neutral-200 dark:border-neutral-700 hover:shadow transition overflow-hidden p-3"
>
<div class="flex-0 h-48 aspect-[10/16] flex flex-col items-center justify-center rounded-lg shadow overflow-hidden">
<div v-if="!video.video_cover" class="w-full h-full bg-primary flex flex-col justify-center items-center gap-2">
<UIcon class="animate-spin text-4xl text-white" name="tabler:loader"/>
<div class="flex flex-col items-center gap-0.5">
<span class="text-sm font-bold text-white/90">火速生成中</span>
<span class="text-xs font-medium text-white/50">{{ video.progress }}%</span>
</div>
</div>
<NuxtImg v-else :src="video.video_cover" class="brightness-90 object-cover"/>
</div>
<div class="flex-1 flex flex-col justify-between gap-2">
<div class="flex-1 rounded-lg bg-neutral-100 dark:bg-neutral-800 p-2 px-2.5">
<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">{{ video.title || '无标题' }}</p>
</li>
<li class="">
<h2 class="text-2xs font-medium text-primary-500">完成时间</h2>
<p class="text-xs line-clamp-1">{{ dayjs(video.complete_time * 1000).format('YYYY-MM-DD HH:mm:ss') }}</p>
</li>
<li class="">
<h2 class="text-2xs font-medium text-primary-500">生成耗时</h2>
<p class="text-xs line-clamp-1">{{ dayjs.duration(video.duration || 0).format('HH:mm:ss') }}</p>
</li>
<li 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-3 text-justify">{{ video.content }}</p>
</li>
</ul>
</div>
<div class="flex justify-end sm:justify-between items-center group flex-nowrap whitespace-nowrap">
<div
class="hidden sm:flex items-center gap-1 transition-all group-hover:opacity-0 group-hover:pointer-events-none">
<UIcon class="text-primary text-lg" name="i-tabler-user-square-rounded"/>
<p class="text-xs">数字人 {{ video.digital_human_id }}</p>
</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"
icon="i-tabler-trash"
size="xs"
variant="soft"
@click="emit('delete', video)"
/>
<UButtonGroup size="xs">
<UButton
:label="downloadingState.subtitle > 0 && downloadingState.subtitle < 100 ? `${downloadingState.subtitle.toFixed(0)}%` : '字幕'"
:loading="downloadingState.subtitle > 0 && downloadingState.subtitle < 100"
color="primary"
leading-icon="i-tabler-file-download"
variant="soft"
@click="startDownload(video.subtitle!, (video.title || video.task_id) + '.ass')"
/>
<UButton
:label="downloadingState.video > 0 && downloadingState.video < 100 ? `${downloadingState.video.toFixed(0)}%` : '视频'"
:loading="downloadingState.video > 0 && downloadingState.video < 100"
color="primary"
leading-icon="i-tabler-download"
variant="soft"
@click="startDownload(video.video_url!, (video.title || video.task_id) + '.mp4')"
/>
</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>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PropType } from 'vue' import type { PropType } from 'vue'
import { encode } from '@monosky/base64'
interface Subtitle { interface Subtitle {
start: string; start: string;
@@ -17,25 +18,29 @@ const props = defineProps({
const dayjs = useDayjs() const dayjs = useDayjs()
const toast = useToast() const toast = useToast()
const loginState = useLoginState()
const isDrawerActive = ref(false) const isDrawerActive = ref(false)
const isLoading = ref(true) const isLoading = ref(true)
const isSaving = ref(false)
const rawSrt = ref<string | null>(null) const rawSrt = ref<string | null>(null)
const subtitles = ref<Subtitle[]>([]) const subtitles = ref<Subtitle[]>([])
const modified = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null) const videoElement = ref<HTMLVideoElement | null>(null)
const loadSrt = async () => { const loadSrt = async () => {
isLoading.value = true isLoading.value = true
try { try {
const response = await fetch(props.course.subtitle_url) // const response = await fetch(props.course.subtitle_url)
const response = await fetch(await fetchCourseSubtitleUrl(props.course))
const text = await response.text() const text = await response.text()
rawSrt.value = text rawSrt.value = text
parseSrt(text) parseSrt(text)
} catch (err) { } catch (err) {
toast.add({ toast.add({
title: '加载字幕失败', title: '加载字幕失败',
description: err as string || '未知错误', description: `${ err }` || '未知错误',
color: 'red', color: 'red',
}) })
} finally { } finally {
@@ -129,6 +134,30 @@ const onSubtitleInputClick = (subtitle: Subtitle) => {
videoElement.value.pause() videoElement.value.pause()
} }
const saveNewSubtitle = () => {
isSaving.value = true
const encodedSubtitle = encode(generateSrt())
useFetchWrapped<
req.gen.CourseSubtitleCreate & AuthedRequest,
BaseResponse<resp.gen.CourseSubtitleCreate>
>('App.Digital_VideoSubtitle.CreateFile', {
token: loginState.token!,
user_id: loginState.user.id,
sub_type: 1,
sub_content: encodedSubtitle,
task_id: props.course?.task_id,
}).then(_ => {
modified.value = false
toast.add({
color: 'green',
title: '字幕已保存',
description: '修改后的字幕文件已保存',
})
}).finally(() => {
isSaving.value = false
})
}
onMounted(() => { onMounted(() => {
if (rawSrt.value) { if (rawSrt.value) {
parseSrt(rawSrt.value) parseSrt(rawSrt.value)
@@ -240,6 +269,7 @@ defineExpose({
:autofocus="false" :autofocus="false"
:color="subtitle.active ? 'primary' : undefined" :color="subtitle.active ? 'primary' : undefined"
@click="onSubtitleInputClick(subtitle)" @click="onSubtitleInputClick(subtitle)"
@input="() => { if(!modified) modified = true }"
> >
<template #trailing> <template #trailing>
<Icon v-if="subtitle.active" name="tabler:keyframe-align-vertical-filled"/> <Icon v-if="subtitle.active" name="tabler:keyframe-align-vertical-filled"/>
@@ -251,12 +281,12 @@ defineExpose({
</div> </div>
<template #footer> <template #footer>
<!-- TODO: 24/07/02 Modified subtitles upload --> <div class="flex justify-end items-center gap-2">
<UButton @click="() => { <span v-if="modified" class="text-sm text-yellow-500 font-medium">已更改但未保存</span>
console.log(generateSrt()) <UButton :disabled="!modified" :loading="isSaving" icon="i-tabler-device-floppy" @click="saveNewSubtitle">
}"> 保存{{ isSaving ? '中' : '' }}
Generate </UButton>
</UButton> </div>
</template> </template>
</UCard> </UCard>
</USlideover> </USlideover>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
const props = defineProps({
icon: {
type: String,
default: 'i-tabler-photo-filled',
},
label: {
type: String,
required: true,
},
to: {
type: String,
required: true,
},
})
const route = useRoute()
const active = computed(() => {
return route.path === props.to
})
</script>
<template>
<NuxtLink
class="px-4 py-3 flex items-center gap-2 rounded-lg transition cursor-pointer"
:class="active ? 'bg-primary text-white' : 'hover:bg-neutral-200'"
:to="to"
>
<Icon :name="icon" class="text-xl inline"/>
<h1 class="text-[14px] font-medium">{{ label }}</h1>
</NuxtLink>
</template>
<style scoped>
</style>

View File

@@ -1,93 +0,0 @@
<script setup lang="ts">
const props = defineProps({
record: {
type: Object,
required: true,
},
})
const dayjs = useDayjs()
</script>
<template>
<div
class="relative w-full aspect-video rounded-lg overflow-hidden shadow"
>
<NuxtImg :src="record.video_cover"></NuxtImg>
<div class="absolute inset-0 flex flex-col justify-between bg-black/10 bg-gradient-to-t from-black/20">
<div class="flex justify-between items-start p-2.5 gap-2">
<div>
<UButton icon="i-solar-play-circle-bold-duotone">预览</UButton>
</div>
<div class="flex flex-col items-end gap-1">
<UTooltip :text="record.task_id" :close-delay="300">
<h1 class="text-white text-xs font-bold font-sans">
ID: {{ record.task_id.slice(0, 6) }}
</h1>
</UTooltip>
<UTooltip :text="dayjs(record.create_time * 1000).format('YYYY-MM-DD HH:mm:ss')">
<h1 class="text-white text-xs font-bold font-sans">
{{ dayjs(record.create_time * 1000).fromNow() }}
</h1>
</UTooltip>
</div>
<!-- <UProgress-->
<!-- size="md"-->
<!-- indicator-->
<!-- :ui="{-->
<!-- wrapper: 'flex-col-reverse',-->
<!-- progress: {-->
<!-- base: '!bg-opacity-50'-->
<!-- }-->
<!-- }"-->
<!-- :value="10"-->
<!-- :max="100"-->
<!-- :animation="'carousel'"-->
<!-- />-->
</div>
<div class="flex justify-between items-center p-2.5 gap-2">
<div class="overflow-hidden whitespace-nowrap">
<UTooltip
:text="record.title"
:popper="{
placement: 'bottom-start'
}"
:open-delay="300"
:close-delay="300"
class="w-full"
>
<h1 class="text-white text-base font-bold font-sans drop-shadow overflow-hidden text-ellipsis leading-none">
{{ record.title }}
</h1>
</UTooltip>
</div>
<div class="flex-1 whitespace-nowrap flex gap-1.5">
<UButton
size="xs"
color="red"
variant="soft"
icon="i-tabler-trash"
/>
<UButton
size="xs"
color="primary"
variant="soft"
icon="i-solar-subtitles-linear"
>
字幕
</UButton>
<UButton
size="xs"
icon="i-tabler-download"
>
下载
</UButton>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,28 @@
export const fetchCourseSubtitleUrl = async (course: resp.gen.CourseGenItem) => {
const loginState = useLoginState()
try {
const subtitleRecord = await useFetchWrapped<
{
page?: number
perpage?: number
task_id: string
} & AuthedRequest,
BaseResponse<PagedData<resp.gen.CourseSubtitleCreate>>
>('App.Digital_VideoSubtitle.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
task_id: course.task_id,
page: 1,
perpage: 1,
})
if (subtitleRecord.data.items.length !== 1) {
return course.subtitle_url
}
return subtitleRecord.data.items[0].url
} catch (err) {
return course.subtitle_url
}
}

View File

@@ -0,0 +1,32 @@
export const useTourState = defineStore('tour_state', () => {
const tourState = ref<{ [key: string]: boolean }>({})
const isTourDone = (tourId: string) => tourState.value[tourId] || false
const setTourDone = (tourId: string) => {
tourState.value = {
...tourState.value,
[tourId]: true,
}
}
const autoDriveTour = (tourId: string, driver: ReturnType<typeof useDriver>) => {
if (isTourDone(tourId)) return
driver.setConfig({
...driver.getConfig(),
onDestroyed: () => setTourDone(tourId),
})
driver.drive()
}
return {
tourState,
isTourDone,
setTourDone,
autoDriveTour,
}
}, {
persist: {
key: 'xsh_assistant_tour_state',
storage: persistedState.localStorage,
paths: ['tourState'],
},
})

View File

@@ -17,18 +17,20 @@ const isDark = computed({
}) })
const links = [ const links = [
{
label: '分身制课',
icon: 'tabler:books',
to: '/generation',
},
{
label: '聊天',
icon: 'tabler:message-chatbot',
to: '/aigc/chat',
},
{ {
label: '绘画', label: '绘画',
icon: 'i-tabler-brush', icon: 'i-tabler-brush',
to: '/', to: '/aigc/draw',
}, {
label: '聊天',
icon: 'i-tabler-message-2',
to: '/aigc/chat',
}, {
label: 'PPT',
icon: 'i-tabler-file-type-ppt',
to: '/aigc/course-generate',
}, },
] ]
@@ -62,7 +64,7 @@ const open_login_modal = () => {
<header> <header>
<h1 class="inline-flex flex-col"> <h1 class="inline-flex flex-col">
<span class="text-lg text-neutral-600 dark:text-neutral-300 font-bold">眩生花 AI 助手</span> <span class="text-lg text-neutral-600 dark:text-neutral-300 font-bold">眩生花 AI 助手</span>
<span class="text-xs text-neutral-600 dark:text-neutral-300">这里可以有一个副标题</span> <span class="text-xs text-neutral-600 dark:text-neutral-300">XSH AI Assistant</span>
</h1> </h1>
<div class="hidden md:block"> <div class="hidden md:block">
<UHorizontalNavigation :links="links" class="select-none"/> <UHorizontalNavigation :links="links" class="select-none"/>
@@ -78,9 +80,20 @@ const open_login_modal = () => {
/> />
<UButton v-if="!loginState.is_logged_in" label="登录或注册" size="xs" class="font-bold" color="indigo" <UButton v-if="!loginState.is_logged_in" label="登录或注册" size="xs" class="font-bold" color="indigo"
@click="open_login_modal"/> @click="open_login_modal"/>
<UDropdown v-if="loginState.is_logged_in" :items="items" :popper="{ placement: 'bottom-start' }" <UDropdown
:ui="{ item: { disabled: 'cursor-text select-text' } }"> v-if="loginState.is_logged_in"
<UAvatar :src="void 0" icon="i-tabler-user" size="md"/> :items="items"
:popper="{ placement: 'bottom-start' }"
:ui="{ item: { disabled: 'cursor-text select-text' } }"
>
<UAvatar
:alt="loginState.user.username.toUpperCase()"
:src="loginState.user.avatar"
chip-color="amber"
chip-position="bottom-right"
chip-text="OP"
size="md"
/>
<template #account="{ item }"> <template #account="{ item }">
<div class="text-left"> <div class="text-left">
<p class="flex items-center gap-1"> <p class="flex items-center gap-1">
@@ -136,6 +149,7 @@ body {
background-clip: content-box; background-clip: content-box;
border: 1px solid transparent border: 1px solid transparent
} }
/* /*
*::-webkit-scrollbar { *::-webkit-scrollbar {
@apply w-1.5 h-1.5; @apply w-1.5 h-1.5;

View File

@@ -1,11 +1,13 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
devtools: {enabled: true}, devtools: { enabled: true },
runtimeConfig: { runtimeConfig: {
public: { public: {
API_BASE: 'https://service1.fenshenzhike.com/', API_BASE: 'https://service2.fenshenzhike.com/',
}, },
}, },
modules: [ modules: [
'@nuxt/ui', '@nuxt/ui',
'radix-vue/nuxt', 'radix-vue/nuxt',
@@ -13,26 +15,45 @@ export default defineNuxtConfig({
'@pinia/nuxt', '@pinia/nuxt',
'@pinia-plugin-persistedstate/nuxt', '@pinia-plugin-persistedstate/nuxt',
'@vite-pwa/nuxt', '@vite-pwa/nuxt',
['@nuxtjs/google-fonts', { '@nuxtjs/google-fonts',
display: 'swap',
families: {
Rubik: '100..900',
'Noto Sans SC': '100..900',
'Barlow Condensed': '100..900'
}
}],
'@nuxt/image', '@nuxt/image',
'@vueuse/nuxt',
'nuxt-driver.js',
], ],
ui: {
icons: ['tabler', 'solar', 'line-md', 'svg-spinners'], routeRules: {
'/': {
redirect: {
to: '/generation',
statusCode: 302,
},
},
}, },
icon: {
provider: 'iconify',
serverBundle: false,
},
colorMode: { colorMode: {
preference: 'dark', preference: 'light',
}, },
dayjs: { dayjs: {
locales: ['zh', 'en'], locales: ['zh', 'en'],
plugins: ['relativeTime', 'utc', 'timezone'], plugins: ['relativeTime', 'utc', 'timezone', 'duration'],
defaultLocale: 'zh', defaultLocale: 'zh',
defaultTimezone: 'Asia/Shanghai', defaultTimezone: 'Asia/Shanghai',
}, },
googleFonts: {
display: 'swap',
families: {
Rubik: '100..900',
'Noto Sans SC': '100..900',
'Barlow Condensed': '100..900',
},
},
compatibilityDate: '2024-07-28',
}) })

View File

@@ -9,34 +9,43 @@
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"packageManager": "pnpm@9.1.3",
"dependencies": { "dependencies": {
"@iconify-json/line-md": "^1.1.36", "@iconify-json/line-md": "^1.1.38",
"@iconify-json/solar": "^1.1.9", "@iconify-json/solar": "^1.1.9",
"@iconify-json/svg-spinners": "^1.1.2", "@iconify-json/svg-spinners": "^1.1.2",
"@iconify-json/tabler": "^1.1.105", "@iconify-json/tabler": "^1.1.118",
"@monosky/base64": "^0.0.3",
"@nuxt/image": "^1.7.0", "@nuxt/image": "^1.7.0",
"@nuxt/ui": "^2.14.1",
"@uniiem/object-trim": "^0.2.0", "@uniiem/object-trim": "^0.2.0",
"@uniiem/uuid": "^0.2.1", "@uniiem/uuid": "^0.2.1",
"events": "^3.3.0", "events": "^3.3.0",
"gsap": "^3.12.5", "gsap": "^3.12.5",
"highlight.js": "^11.9.0", "highlight.js": "^11.10.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nuxt": "^3.10.3", "nuxt": "^3.12.4",
"radix-vue": "^1.4.9", "nuxt-driver.js": "^0.0.11",
"vue": "^3.4.19", "radix-vue": "^1.9.2",
"vue-router": "^4.3.0", "vue": "^3.4.34",
"vue-router": "^4.4.0",
"yup": "^1.4.0" "yup": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/ui": "^2.18.3",
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
"@pinia-plugin-persistedstate/nuxt": "^1.2.0", "@pinia-plugin-persistedstate/nuxt": "^1.2.1",
"@pinia/nuxt": "^0.5.1", "@pinia/nuxt": "^0.5.2",
"@tailwindcss/typography": "^0.5.12", "@tailwindcss/typography": "^0.5.13",
"@types/markdown-it": "^13.0.7", "@types/markdown-it": "^13.0.9",
"@vite-pwa/nuxt": "^0.5.0", "@vite-pwa/nuxt": "^0.5.0",
"@vueuse/core": "^10.11.1",
"@vueuse/nuxt": "^10.11.1",
"dayjs-nuxt": "^2.1.9", "dayjs-nuxt": "^2.1.9",
"sass": "^1.72.0" "sass": "^1.77.8"
},
"peerDependencies": {
"dayjs": "^1.11.12",
"tailwindcss": "^3.4.7"
} }
} }

View File

@@ -1,75 +0,0 @@
<script lang="ts" setup>
import CourseGenerate from '~/components/aigc/course-generate/CourseGenerate.vue'
import CourseGenerateGreenScreen from '~/components/aigc/course-generate/CourseGenerateGreenScreen.vue'
useHead({
title: 'PPT 生成视频 | XSH AI',
})
const showSidebar = ref(false)
const toggleSidebar = () => {
showSidebar.value = !showSidebar.value
}
const routes = [
{
name: '微课视频生成',
component: CourseGenerate,
},
{
name: '绿幕视频生成',
component: CourseGenerateGreenScreen,
},
]
const currentComponentIndex = ref(0)
</script>
<template>
<div class="w-full flex relative">
<div
:class="{'translate-x-0': showSidebar}"
class="absolute -translate-x-full md:sticky md:translate-x-0 z-10 flex flex-col h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 w-full md:w-[300px]
shadow-sidebar border-r border-transparent dark:border-neutral-700 transition-all duration-300 ease-out"
>
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
<div class="flex flex-col gap-3 relative">
<div class="space-y-2 nav">
<div
v-for="(route, index) in routes"
:key="index"
:class="['nav-item', { active: currentComponentIndex === index }]"
@click="currentComponentIndex = index"
>
{{ route.name }}
</div>
</div>
</div>
</div>
</div>
<div class="h-[calc(100vh-4rem)] flex-1 overflow-y-auto bg-white dark:bg-neutral-900">
<ClientOnly>
<Component :is="routes[currentComponentIndex].component"/>
</ClientOnly>
</div>
</div>
</template>
<style scoped>
.nav {
&-item {
@apply w-full px-4 py-3 bg-transparent rounded-md cursor-pointer select-none;
@apply transition duration-150 ease-out;
&:hover {
@apply bg-black/10;
}
&.active {
@apply bg-primary/20 text-primary shadow shadow-primary/20 font-medium;
}
}
}
</style>

416
pages/aigc/draw/index.vue Normal file
View File

@@ -0,0 +1,416 @@
<script lang="ts" setup>
import OptionBlock from '~/components/aigc/drawing/OptionBlock.vue'
import ResultBlock from '~/components/aigc/drawing/ResultBlock.vue'
import { useLoginState } from '~/composables/useLoginState'
import ModalAuthentication from '~/components/ModalAuthentication.vue'
import { type InferType, number, object, string } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
import RatioSelector from '~/components/aigc/RatioSelector.vue'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
import type { ResultBlockMeta } from '~/components/aigc/drawing'
import { useHistory } from '~/composables/useHistory'
import { del, set } from 'idb-keyval'
import ReferenceFigureSelector from '~/components/aigc/ReferenceFigureSelector.vue'
useSeoMeta({
title: '绘画',
})
const toast = useToast()
const modal = useModal()
const dayjs = useDayjs()
const history = useHistory()
const loginState = useLoginState()
const leftSection = ref<HTMLElement | null>(null)
const leftHandler = ref<HTMLElement | null>(null)
const showSidebar = ref(false)
const generating = ref(false)
const handle_stick_mousedown = (e: MouseEvent, min: number = 240, max: number = 400) => {
const handler = leftHandler.value
if (handler) {
const startX = e.clientX
const startWidth = handler.parentElement?.offsetWidth || 0
const handle_mousemove = (e: MouseEvent) => {
let newWidth = startWidth + e.clientX - startX
if (newWidth < min || newWidth > max) {
newWidth = Math.min(Math.max(newWidth, min), max)
}
handler.parentElement!.style.width = `${ newWidth }px`
}
const handle_mouseup = () => {
leftSection.value?.classList.add('transition-all')
leftHandler.value?.lastElementChild?.classList.remove('bg-indigo-300', 'dark:bg-indigo-700', 'w-[3px]')
window.removeEventListener('mousemove', handle_mousemove)
window.removeEventListener('mouseup', handle_mouseup)
}
leftSection.value?.classList.remove('transition-all')
leftHandler.value?.lastElementChild?.classList.add('bg-indigo-300', 'dark:bg-indigo-700', 'w-[3px]')
window.addEventListener('mousemove', handle_mousemove)
window.addEventListener('mouseup', handle_mouseup)
}
}
const defaultRatios = [
{
ratio: '1:1',
value: '768:768',
},
{
ratio: '4:3',
value: '1024:768',
},
{
ratio: '3:4',
value: '768:1024',
},
]
interface StyleItem {
label: string
value: number
avatar?: { src: string }
}
const defaultStyles: StyleItem[] = [
{
label: '通用写实风格',
value: 401,
},
{
label: '日系动漫',
value: 201,
},
{
label: '科幻风格',
value: 114,
},
{
label: '怪兽风格',
value: 202,
},
{
label: '唯美古风',
value: 203,
},
{
label: '复古动漫',
value: 204,
},
{
label: '游戏卡通手绘',
value: 301,
},
{
label: '水墨画',
value: 101,
},
{
label: '概念艺术',
value: 102,
},
{
label: '水彩画',
value: 104,
},
{
label: '像素画',
value: 105,
},
{
label: '厚涂风格',
value: 106,
},
{
label: '插图',
value: 107,
},
{
label: '剪纸风格',
value: 108,
},
{
label: '印象派',
value: 119,
},
{
label: '印象派(莫奈)',
value: 109,
},
{
label: '油画',
value: 103,
},
{
label: '油画(梵高)',
value: 118,
},
{
label: '古典肖像画',
value: 111,
},
{
label: '黑白素描画',
value: 112,
},
{
label: '赛博朋克',
value: 113,
},
{
label: '暗黑风格',
value: 115,
},
{
label: '蒸汽波',
value: 117,
},
{
label: '2.5D',
value: 110,
},
{
label: '3D',
value: 116,
},
]
const img2imgStyles: StyleItem[] = [
{
label: '水彩画',
value: 106,
},
{
label: '2.5D',
value: 110,
},
{
label: '日系动漫',
value: 201,
},
{
label: '美系动漫',
value: 202,
},
{
label: '唯美古风',
value: 203,
},
]
const defaultFormSchema = object({
prompt: string().required('请输入提示词'),
negative_prompt: string(),
resolution: string().required('请选择分辨率'),
styles: object<StyleItem>({
label: string(),
value: number(),
}).required('请选择风格'),
file: string().nullable(),
})
type DefaultFormSchema = InferType<typeof defaultFormSchema>
const defaultFormState = reactive({
prompt: '',
negative_prompt: '',
resolution: '1024:768',
styles: defaultStyles.find(item => item.value === 401),
file: null,
})
watch(() => defaultFormState.file, (newVal) => {
if (newVal) {
defaultFormState.styles = img2imgStyles[0]
} else {
defaultFormState.styles = defaultStyles.find(item => item.value === 401)
}
})
const onDefaultFormSubmit = (event: FormSubmitEvent<DefaultFormSchema>) => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
generating.value = true
const styleItem = event.data.styles as StyleItem
if (!event.data.file) delete event.data.file
// generate a uuid
const fid = Math.random().toString(36).substring(2)
const meta: ResultBlockMeta = {
cost: '1000',
modal: '混元大模型',
style: styleItem.label,
ratio: event.data.resolution,
datetime: dayjs().unix(),
type: event.data.file ? '智能图生图' : '智能文生图',
}
history.text2img.unshift({
fid,
meta,
prompt: event.data.prompt,
})
useFetchWrapped<
(HunYuan.Text2Img.req | HunYuan.Img2Img.req) & AuthedRequest,
BaseResponse<HunYuan.resp>
>(event.data.file ? 'App.Assistant_HunYuan.TenImgToImg' : 'App.Assistant_HunYuan.TenTextToImg', {
token: loginState.token as string,
user_id: loginState.user.id,
device_id: 'web',
...event.data,
styles: styleItem.value,
}).then(res => {
if (res.ret !== 200) {
toast.add({
title: '生成失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
history.text2img = history.text2img.filter(item => item.fid !== fid)
return
}
history.text2img = history.text2img.map(item => {
if (item.fid === fid) {
set(`${ item.fid }`, [`data:image/png;base64,${ res.data.request_image }`])
item.meta = {
...item.meta,
id: res.data.data_id as string,
}
}
return item
})
}).catch(err => {
toast.add({
title: '生成失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
}).finally(() => {
generating.value = false
})
}
</script>
<template>
<div class="w-full flex relative">
<div ref="leftSection"
:class="{'translate-x-0': showSidebar}"
class="absolute -translate-x-full md:sticky md:translate-x-0 z-10 md:block h-[calc(100vh-4rem)] bg-neutral-200 dark:bg-neutral-800 transition-all" style="width: 320px">
<div ref="leftHandler"
class="absolute inset-0 left-auto hidden xl:flex flex-col justify-center items-center cursor-ew-resize px-1 group"
@dblclick="leftSection?.style.setProperty('width', '320px')"
@mousedown.prevent="handle_stick_mousedown">
<span
class="w-[1px] h-full bg-neutral-300 dark:bg-neutral-700 group-hover:bg-indigo-300 dark:group-hover:bg-indigo-700 group-hover:w-[3px] transition-all group-hover:delay-500 translate-x-1"></span>
</div>
<div
class="absolute bottom-28 -right-12 w-12 h-12 z-10 bg-neutral-100 dark:bg-neutral-900 rounded-r-lg shadow-lg flex md:hidden justify-center items-center">
<UButton color="black" icon="i-tabler-brush" size="lg" square @click="showSidebar = !showSidebar"></UButton>
</div>
<div class="h-full flex flex-col overflow-y-auto">
<UForm :schema="defaultFormSchema" :state="defaultFormState" @submit="onDefaultFormSubmit">
<div class="flex flex-col gap-2 p-4 pb-28">
<OptionBlock comment="Prompts" icon="i-tabler-article" label="提示词">
<UFormGroup name="prompt">
<UTextarea v-model="defaultFormState.prompt" :rows="2" autoresize
placeholder="请输入提示词,每个提示词之间用英文逗号隔开" resize/>
</UFormGroup>
</OptionBlock>
<OptionBlock comment="Negative Prompts" icon="i-tabler-article-off" label="负面提示词">
<UFormGroup name="negative_prompt">
<UTextarea v-model="defaultFormState.negative_prompt" :rows="2" autoresize
placeholder="请输入作品中不要出现的提示词,每个提示词之间用英文逗号隔开"
resize/>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-library-photo" label="参考图片">
<UFormGroup name="input_image">
<ReferenceFigureSelector
:value="defaultFormState.file"
text="选择参考图片"
text-on-select="已选择参考图" @update="file => {defaultFormState.file = file}"/>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-photo-hexagon" label="图片风格">
<UFormGroup name="styles">
<USelectMenu v-model="defaultFormState.styles"
:options="defaultFormState.file ? img2imgStyles : defaultStyles"></USelectMenu>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-article-off" label="图片比例">
<UFormGroup name="resolution">
<RatioSelector v-model="defaultFormState.resolution" :ratios="defaultRatios"/>
</UFormGroup>
</OptionBlock>
</div>
<div class="absolute bottom-0 inset-x-0 flex flex-col items-center gap-2
bg-neutral-200 dark:bg-neutral-800 p-4 border-t border-neutral-400
dark:border-neutral-700">
<UButton :loading="generating" block class="font-bold" color="indigo" size="lg" type="submit">
{{ generating ? '生成中' : '生成' }}
</UButton>
<p class="text-xs text-neutral-400 dark:text-neutral-500 font-bold">
生成即代表您同意<a class="underline underline-offset-2" href="#"
target="_blank">用户许可协议</a>
</p>
</div>
</UForm>
</div>
</div>
<ClientOnly>
<div class="flex-1 h-screen flex flex-col gap-4 bg-neutral-100 dark:bg-neutral-900 p-4 pb-20 overflow-y-auto">
<div v-if="!loginState.is_logged_in"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="i-tabler-user-circle"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton class="mt-2 font-bold" color="black" size="xs" variant="solid"
@click="modal.open(ModalAuthentication)">
登录
</UButton>
</div>
<div v-else-if="history.text2img.length === 0"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="i-tabler-photo-hexagon"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">没有记录</p>
</div>
<ResultBlock v-for="(result, k) in history.text2img" v-else :key="result.fid" :fid="result.fid"
:meta="result.meta" :prompt="result.prompt"
@use-reference="file => {defaultFormState.file = file}">
<template #header-right>
<UPopover overlay>
<UButton color="black" icon="i-tabler-trash" size="xs" variant="ghost"></UButton>
<template #panel="{close}">
<div class="p-4 flex flex-col gap-4">
<h2 class="text-sm">删除后无法恢复,确定删除?</h2>
<div class="flex items-center justify-end gap-2">
<UButton class="font-bold" color="gray" size="xs" @click="close">
取消
</UButton>
<UButton class="font-bold" color="red" size="xs"
@click="() => {
history.text2img.splice(k, 1)
del(result.fid)
close()
}">
仍然删除
</UButton>
</div>
</div>
</template>
</UPopover>
</template>
</ResultBlock>
<div class="flex justify-center items-center gap-1 text-neutral-400 dark:text-neutral-600">
<UIcon name="i-tabler-info-triangle"/>
<p class="text-xs font-bold">所有图片均为 AI 生成服务器不会保存任何图像数据仅保存在浏览器本地</p>
</div>
</div>
</ClientOnly>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,53 +0,0 @@
<script setup lang="tsx">
import NavItem from '~/components/ppt/NavItem.vue'
useSeoMeta({
title: '智能生成',
})
</script>
<template>
<div class="w-full flex relative">
<div class="absolute -translate-x-full md:sticky md:translate-x-0 z-10 flex flex-col h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 w-full md:w-[300px]
border-r border-neutral-200 dark:border-neutral-700 transition-all duration-300 ease-out">
<div class="flex flex-col flex-1 overflow-auto overflow-x-hidden">
<div class="flex flex-col gap-1">
<ClientOnly>
<NavItem
icon="tabler:presentation-analytics"
label="微课视频生成"
to="/aigc/generation/video-generate"
/>
<NavItem
icon="tabler:user-screen"
label="数字讲师"
to="/aigc/generation/digital-teachers"
/>
</ClientOnly>
</div>
</div>
</div>
<LoginNeededContent
content-class="h-[calc(100vh-4rem)] flex-1 bg-white dark:bg-neutral-900"
>
<Transition name="subpage" mode="out-in">
<NuxtPage :page-key="route => route.fullPath"/>
</Transition>
</LoginNeededContent>
</div>
</template>
<style>
.subpage-enter-active,
.subpage-leave-active {
transition: opacity 0.3s;
}
.subpage-enter-from,
.subpage-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
useSeoMeta({
title: '数字化教师',
})
</script>
<template>
<div class="p-4">
<BubbleTitle title="数字化讲师" subtitle="DIGITAL" />
<GradientDivider />
</div>
</template>
<style scoped>
</style>

View File

@@ -1,69 +0,0 @@
<script setup lang="ts">
useSeoMeta({
title: '课程视频生成',
})
const testItem = {
'id': 1599,
'device_id': 'Test_Device_V3',
'user_id': 1,
'task_id': 'SQOeN1j2heRoQeGGTFh3Tu2WP9kUcz4L',
'create_time': 1713408239,
'token': 'not use',
'progress': 100,
'digital_human_id': 40696,
'complete_time': 1713409821,
'duration': 1578819,
'video_url': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-04-18/75d1e1cee595a7f5758c59289d1a74b9.mp4',
'subtitle_url': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-04-18/a95cfef4524e90f5509a5c248e5c2061.srt',
'video_cover': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-04-18/b4161a85573fc09be82fa7cf7dd9abfa.png',
'custom_video': '[]',
'title': '1-2 一键启动零基础快速搭建Keil开发环境实操教程',
'ppt_url': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/material/2024-04-18/0a8827a1ae32ece196536a19bab1dff5.pptx',
'opening_url': '',
'ending_url': '',
'video_duration': 507,
'message': 'ok',
'speed': 1,
}
const testItem2 = {
'id': 1599,
'device_id': 'Test_Device_V3',
'user_id': 1,
'task_id': 'SQOeN1j2heRoQeGGTFh3Tu2WP9kUcz4L',
'create_time': 1713408239,
'token': 'not use',
'progress': null,
'digital_human_id': 40696,
'complete_time': 1713409821,
'duration': 1578819,
'video_url': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-04-18/75d1e1cee595a7f5758c59289d1a74b9.mp4',
'subtitle_url': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-04-18/a95cfef4524e90f5509a5c248e5c2061.srt',
'video_cover': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-04-18/b4161a85573fc09be82fa7cf7dd9abfa.png',
'custom_video': '[]',
'title': '1-2 一键启动零基础快速搭建Keil开发环境实操教程',
'ppt_url': 'https://static-xsh.oss-cn-chengdu.aliyuncs.com/material/2024-04-18/0a8827a1ae32ece196536a19bab1dff5.pptx',
'opening_url': '',
'ending_url': '',
'video_duration': 507,
'message': 'ok',
'speed': 1,
}
</script>
<template>
<div class="p-4">
<BubbleTitle title="我的微课" subtitle="VIDEOS"/>
<GradientDivider/>
<div class="grid grid-cols-3 gap-4">
<PPTGenerationRecord :record="testItem"/>
<PPTGenerationRecord :record="testItem2"/>
</div>
</div>
</template>
<style scoped>
</style>

113
pages/generation.vue Normal file
View File

@@ -0,0 +1,113 @@
<script setup lang="tsx">
import NavItem from '~/components/aigc/NavItem.vue'
useSeoMeta({
title: '智能生成',
})
const navList = ref<{
label: string
icon: string
to: string
admin?: boolean
}[]>([
{
label: '微课视频生成',
icon: 'tabler:presentation-analytics',
to: '/generation/course',
},
{
label: '绿幕视频生成',
icon: 'i-tabler-video',
to: '/generation/green-screen',
},
{
label: '用户管理',
icon: 'tabler:users',
to: '/generation/admin/users',
admin: true,
},
])
const route = useRoute()
const router = useRouter()
const loginState = useLoginState()
onMounted(() => {
if (route.fullPath === '/generation') {
router.replace('/generation/course')
}
})
</script>
<template>
<div class="w-full flex relative">
<div class="absolute -translate-x-full md:sticky md:translate-x-0 z-10 flex flex-col h-[calc(100vh-4rem)] bg-neutral-100 dark:bg-neutral-900 p-4 w-full md:w-[300px]
border-r border-neutral-200 dark:border-neutral-700 transition-all duration-300 ease-out">
<div class="flex flex-col flex-1 overflow-auto overflow-x-hidden">
<div class="flex flex-col gap-1">
<ClientOnly>
<NavItem
v-for="(item, i) in navList"
:key="i"
:icon="item.icon"
:label="item.label"
:to="item.to"
:admin="item.admin"
:hide="item.admin && loginState.user.auth_code !== 2"
/>
</ClientOnly>
</div>
</div>
</div>
<LoginNeededContent
content-class="h-[calc(100vh-4rem)] flex-1 overflow-y-auto bg-white dark:bg-neutral-900"
>
<Transition name="subpage" mode="out-in">
<div>
<Suspense>
<NuxtPage :page-key="route.fullPath" keepalive/>
</Suspense>
</div>
</Transition>
</LoginNeededContent>
</div>
</template>
<style>
.subpage-enter-active,
.subpage-leave-active {
@apply transition-all duration-300;
}
.subpage-enter-from,
.subpage-leave-to {
@apply opacity-0 translate-x-4;
}
.loading-screen-leave-active {
@apply transition-all duration-300;
}
.loading-screen-leave-to {
@apply opacity-0;
}
.card-move,
.card-enter-active,
.card-leave-active {
@apply transition-all duration-300;
}
.card-enter-from,
.card-leave-to {
@apply opacity-0;
}
.card-leave-active {
@apply absolute;
}
</style>

View File

@@ -0,0 +1,414 @@
<script lang="ts" setup>
import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
const toast = useToast()
const loginState = useLoginState()
const columns = [
{
key: 'id',
label: 'ID',
disabled: true,
},
{
key: 'avatar',
label: '头像',
disabled: true,
},
{
key: 'username',
label: '用户名',
disabled: true,
},
{
key: 'company',
label: '单位',
},
{
key: 'mobile',
label: '手机号',
},
{
key: 'email',
label: '电子邮箱',
},
{
key: 'nickname',
label: '昵称',
},
{
key: 'sex',
label: '性别',
},
{
key: 'auth_code',
label: '角色',
},
{
key: 'actions',
label: '操作',
},
]
const selectedColumns = ref([...columns.filter(row => {
return !['nickname', 'email', 'auth_code'].includes(row.key)
})])
const page = ref(1)
const pageCount = ref(15)
const is_verified = ref(true)
const viewingUser = ref<UserSchema | null>(null)
const isSlideOpen = computed({
get: () => !!viewingUser.value,
set: () => viewingUser.value = null,
})
watch([is_verified, pageCount], () => page.value = 1)
const {
data: usersData,
refresh: refreshUsersData,
status: usersDataStatus,
} = useAsyncData(
'systemUsers',
() => useFetchWrapped<
req.user.UserList & AuthedRequest,
BaseResponse<PagedData<UserSchema>>
>('App.User_User.ListUser', {
token: loginState.token!,
user_id: loginState.user.id!,
page: page.value,
perpage: pageCount.value,
is_verify: is_verified.value,
}),
{
watch: [page, pageCount, is_verified],
},
)
const isDigitalSelectorOpen = ref(false)
const dhPage = ref(1)
const dhPageCount = ref(10)
watch(dhPageCount, () => dhPage.value = 1)
const {
data: digitalHumansData,
refresh: refreshDigitalHumansData,
status: digitalHumansDataStatus,
} = useAsyncData(
'currentUserDigitalHumans',
() => useFetchWrapped<
PagedDataRequest & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.User_UserDigital.GetList', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
page: dhPage.value,
perpage: dhPageCount.value,
}),
{
watch: [viewingUser, dhPage, dhPageCount],
},
)
const items = (row: UserSchema) => [
[
{
label: '数字人授权',
icon: 'tabler:user-cog',
click: () => openSlide(row),
disabled: row.auth_code === 0,
},
],
[
{
label: row.auth_code !== 0 ? '停用账号' : '启用账号',
icon: row.auth_code !== 0 ? 'tabler:cancel' : 'tabler:shield-check',
click: () => setUserStatus(row.id, row.auth_code === 0),
},
],
]
const openSlide = (user: UserSchema) => {
viewingUser.value = user
}
const closeSlide = () => {
viewingUser.value = null
}
const onDigitalHumansSelected = (digitalHumans: DigitalHumanItem[]) => {
useFetchWrapped<
{
to_user_id: number
digital_human_array: number[]
} & AuthedRequest,
BaseResponse<{
total: number
success: number
failed: number
}>
>('App.User_UserDigital.CreateConnArr', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
digital_human_array: digitalHumans.map(row => row.id || row.digital_human_id),
}).then(res => {
if (res.ret === 200) {
toast.add({
title: '授权成功',
description: `成功授权 ${ res.data.success } 个数字人${ res.data.failed ? `,失败 ${ res.data.failed }` : '' }`,
color: 'green',
icon: 'tabler:check',
})
refreshDigitalHumansData()
} else {
toast.add({
title: '授权失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
})
}
const revokeDigitalHuman = (uid: number, digitalHumanId: number) => {
useFetchWrapped<
{
to_user_id: number
digital_human_id: number
} & AuthedRequest,
BaseResponse<{
code: number // 1: success, 0: failed
}>
>('App.User_UserDigital.DeleteConn', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: uid,
digital_human_id: digitalHumanId,
}).then(res => {
if (res.ret === 200 && res.data.code === 1) {
toast.add({
title: '撤销成功',
description: '已撤销数字人授权',
color: 'green',
icon: 'tabler:check',
})
refreshDigitalHumansData()
} else {
toast.add({
title: '撤销失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
})
}
const setUserStatus = (uid: number, is_verified: boolean) => {
useFetchWrapped<
{
to_user_id: number
is_verify: boolean
} & AuthedRequest,
BaseResponse<{
status: number // 1: success, 0: failed
}>
>('App.User_User.SetUserVerify', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: uid,
is_verify: is_verified,
}).then(res => {
if (res.ret === 200 && res.data.status === 1) {
toast.add({
title: '操作成功',
description: `${ is_verified ? '启用' : '停用' }账号`,
color: 'green',
icon: is_verified ? 'tabler:shield-check' : 'tabler:cancel',
})
refreshUsersData()
} else {
toast.add({
title: '操作失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
})
}
</script>
<template>
<LoginNeededContent need-admin>
<div class="p-4">
<BubbleTitle bubble-color="amber-500" subtitle="User Management" title="用户管理">
<template #action>
<UButton
color="amber"
icon="tabler:users"
variant="soft"
@click="openSlide(loginState.user)"
>
本账号数字人
</UButton>
<UButton
color="amber"
icon="tabler:reload"
variant="soft"
@click="refreshUsersData"
>
刷新
</UButton>
</template>
</BubbleTitle>
<GradientDivider line-gradient-from="amber" line-gradient-to="amber"/>
<div>
<div class="flex justify-between items-center w-full py-3">
<div class="flex items-center gap-1.5">
<span class="text-sm leading-0">每页显示:</span>
<USelect
v-model="pageCount"
:options="[5, 10, 15, 20]"
class="me-2 w-20"
size="xs"
/>
</div>
<div class="flex gap-1.5 items-center">
<USelectMenu
v-model="is_verified"
:options="[
{label: '正常账号', value: true, icon: 'tabler:user-check'},
{label: '停用账号', value: false, icon: 'tabler:user-cancel'},
]"
:ui-menu="{width: 'w-fit', option: {size: 'text-xs', icon: {base: 'w-4 h-4'}}}"
size="xs"
value-attribute="value"
/>
<USelectMenu
v-model="selectedColumns"
:options="columns.filter(row => !['actions'].includes(row.key))"
:ui-menu="{width: 'w-fit', option: {size: 'text-xs', icon: {base: 'w-4 h-4'}}}"
multiple
>
<UButton
color="gray"
icon="tabler:layout-columns"
size="xs"
>
显示列
</UButton>
</USelectMenu>
</div>
</div>
<UTable
:columns="selectedColumns"
:loading="usersDataStatus === 'pending'"
:progress="{color: 'amber', animation: 'carousel'}"
:rows="usersData?.data.items"
class="border dark:border-neutral-800 rounded-md"
>
<template #avatar-data="{ row }">
<UAvatar :alt="row.username.toUpperCase()" :src="row.avatar" size="sm"/>
</template>
<template #sex-data="{ row }">
{{ row.sex === 0 ? '' : row.sex === 1 ? '男' : '女' }}
</template>
<template #actions-data="{ row }">
<UDropdown :items="items(row)">
<UButton color="gray" icon="tabler:dots" variant="ghost"/>
</UDropdown>
</template>
</UTable>
<div class="flex justify-end py-3.5">
<UPagination
v-if="(usersData?.data.total || -1) > 0"
v-model="page"
:page-count="pageCount"
:total="usersData?.data.total || 0"
/>
</div>
</div>
</div>
<USlideover v-model="isSlideOpen">
<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>
<UButton
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="tabler:x"
padded
size="sm"
square
variant="ghost"
@click="closeSlide"
/>
数字人授权管理
<p class="text-sm font-medium text-primary">{{ viewingUser?.username }} (UID:{{ viewingUser?.id }})</p>
</template>
<div class="flex w-full justify-end pb-4">
<UButton
icon="tabler:plus"
size="xs"
@click="isDigitalSelectorOpen = true"
>
新增授权
</UButton>
</div>
<div class="border dark:border-neutral-700 rounded-md">
<UTable
:columns="[
{key: 'name', label: '名称'},
{key: 'digital_human_id', label: '平台ID'},
{key: 'model_id', label: '上游ID'},
{key: 'actions'},
]"
:loading="digitalHumansDataStatus === 'pending'"
:rows="digitalHumansData?.data.items"
>
<template #actions-data="{ row }">
<UButton
color="gray"
icon="tabler:cancel"
size="xs"
variant="ghost"
@click="revokeDigitalHuman(viewingUser?.id || 0, row.digital_human_id)"
>
撤销授权
</UButton>
</template>
</UTable>
</div>
<div class="flex justify-end py-3.5">
<UPagination
v-if="(digitalHumansData?.data.total || -1) > 0"
v-model="dhPage"
:page-count="dhPageCount"
:total="digitalHumansData?.data.total || 0"
/>
</div>
</UCard>
<ModalDigitalHumanSelect
:disabled-digital-human-ids="digitalHumansData?.data.items.map(d => d.model_id)"
:is-open="isDigitalSelectorOpen"
default-tab="system"
multiple
@close="isDigitalSelectorOpen = false"
@select="digitalHumans => onDigitalHumansSelected(digitalHumans as DigitalHumanItem[])"
/>
</USlideover>
</LoginNeededContent>
</template>
<style scoped>
</style>

151
pages/generation/course.vue Normal file
View File

@@ -0,0 +1,151 @@
<script lang="ts" setup>
import CGTaskCard from '~/components/aigc/generation/CGTaskCard.vue'
import ModalAuthentication from '~/components/ModalAuthentication.vue'
import SlideCreateCourse from '~/components/SlideCreateCourse.vue'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const toast = useToast()
const modal = useModal()
const slide = useSlideover()
const loginState = useLoginState()
const deletePending = ref(false)
const page = ref(1)
const {
data: courseList,
refresh: refreshCourseList,
} = useAsyncData(
() => useFetchWrapped<
req.gen.CourseGenList & AuthedRequest,
BaseResponse<PagedData<resp.gen.CourseGenItem>>
>('App.Digital_Convert.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 16,
}), {
watch: [page],
},
)
const onCreateCourseClick = () => {
slide.open(SlideCreateCourse, {
onSuccess: () => {
refreshCourseList()
},
})
}
const onCourseDelete = (task_id: string) => {
if (!task_id) return
deletePending.value = true
useFetchWrapped<
req.gen.CourseGenDelete & AuthedRequest,
BaseResponse<resp.gen.CourseGenDelete>
>('App.Digital_Convert.Delete', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
task_id,
}).then(res => {
if (res.ret === 200) {
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
}).finally(() => {
deletePending.value = false
refreshCourseList()
})
}
const beforeLeave = (el: any) => {
el.style.width = `${ el.offsetWidth }px`
el.style.height = `${ el.offsetHeight }px`
}
const leave = (el: any, done: Function) => {
el.style.position = 'absolute'
el.style.transition = 'none' // 取消过渡动画
el.style.opacity = 0 // 立即隐藏元素
done()
}
onMounted(() => {
const i = setInterval(refreshCourseList, 1000 * 5)
onBeforeUnmount(() => clearInterval(i))
})
</script>
<template>
<div>
<div class="p-4 pb-0">
<BubbleTitle subtitle="VIDEOS" title="我的微课视频">
<template #action>
<UButton
:trailing="false"
color="primary"
icon="i-tabler-plus"
label="新建微课"
size="md"
variant="solid"
@click="() => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
onCreateCourseClick()
}"
/>
</template>
</BubbleTitle>
<GradientDivider/>
</div>
<Transition name="loading-screen">
<div
v-if="courseList?.data.items.length === 0"
class="w-full py-20 flex flex-col justify-center items-center gap-2"
>
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="i-tabler-photo-hexagon"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
没有记录
</p>
</div>
<div v-else class="p-4">
<div class="relative grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 fhd:grid-cols-5 gap-4">
<TransitionGroup
name="card"
@beforeLeave="beforeLeave"
@leave="leave"
>
<CGTaskCard
v-for="(course, index) in courseList?.data.items"
:key="course.task_id || 'unknown' + index"
:course="course"
@delete="task_id => onCourseDelete(task_id)"
/>
</TransitionGroup>
</div>
<div class="flex justify-end mt-4">
<UPagination v-model="page" :max="9" :page-count="16" :total="courseList?.data.total || 0"/>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,207 @@
<script lang="ts" setup>
import { useFetchWrapped } from '~/composables/useFetchWrapped'
import GBTaskCard from '~/components/aigc/generation/GBTaskCard.vue'
import { useTourState } from '~/composables/useTourState'
import SlideCreateCourseGreen from '~/components/SlideCreateCourseGreen.vue'
const route = useRoute()
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
const tourState = useTourState()
const page = ref(1)
const pageCount = ref(15)
const searchInput = ref('')
const debounceSearch = refDebounced(searchInput, 1000)
watch(debounceSearch, () => page.value = 1)
const {
data: videoList,
refresh: refreshVideoList,
} = useAsyncData(
() => useFetchWrapped<
req.gen.GBVideoList & AuthedRequest,
BaseResponse<PagedData<GBVideoItem>>
>('App.Digital_VideoTask.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: pageCount.value,
title: debounceSearch.value,
}), {
watch: [page, pageCount, debounceSearch],
},
)
const onCreateCourseGreenClick = () => {
slide.open(SlideCreateCourseGreen, {
onSuccess: () => {
refreshVideoList()
},
})
}
const onCourseGreenDelete = (task: GBVideoItem) => {
if (!task.task_id) return
useFetchWrapped<
req.gen.GBVideoDelete & AuthedRequest,
BaseResponse<resp.gen.GBVideoDelete>
>('App.Digital_VideoTask.Delete', {
token: loginState.token!,
user_id: loginState.user.id,
task_id: task.task_id,
}).then(res => {
if (res.data.code === 1) {
refreshVideoList()
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
})
}
const beforeLeave = (el: any) => {
el.style.width = `${ el.offsetWidth }px`
el.style.height = `${ el.offsetHeight }px`
}
const leave = (el: any, done: Function) => {
el.style.position = 'absolute'
el.style.transition = 'none' // 取消过渡动画
el.style.opacity = 0 // 立即隐藏元素
done()
}
onMounted(() => {
const i = setInterval(refreshVideoList, 1000 * 5)
onBeforeUnmount(() => clearInterval(i))
const driver = useDriver({
showProgress: true,
animate: true,
smoothScroll: true,
disableActiveInteraction: true,
popoverOffset: 12,
progressText: '{{current}} / {{total}}',
prevBtnText: '上一步',
nextBtnText: '下一步',
doneBtnText: '完成',
steps: [
{
element: '#button-create',
popover: {
title: '新建视频',
description: '点击这里开始新建绿幕视频',
},
},
{
element: '#input-search',
popover: {
title: '搜索生成记录',
description: '在这里输入视频标题,可以搜索符合条件的生成记录',
},
},
],
})
tourState.autoDriveTour(route.fullPath, driver)
})
</script>
<template>
<div class="h-full">
<div class="p-4 pb-0">
<BubbleTitle
:subtitle="!debounceSearch ? 'GB VIDEOS' : 'SEARCH...'"
:title="!debounceSearch ? '我的绿幕视频' : `标题搜索:${debounceSearch.toLocaleUpperCase()}`"
>
<template #action>
<UButtonGroup size="md">
<UInput
id="input-search"
v-model="searchInput"
:autofocus="false"
:ui="{ icon: { trailing: { pointer: '' } } }"
autocomplete="off"
placeholder="标题搜索"
variant="outline"
>
<template #trailing>
<UButton
v-show="searchInput !== ''"
:padded="false"
color="gray"
icon="i-tabler-x"
variant="link"
@click="searchInput = ''"
/>
</template>
</UInput>
</UButtonGroup>
<UButton
id="button-create"
:trailing="false"
color="primary"
icon="i-tabler-plus"
label="新建"
size="md"
variant="solid"
@click="onCreateCourseGreenClick"
/>
</template>
</BubbleTitle>
<GradientDivider/>
</div>
<Transition name="loading-screen">
<div
v-if="videoList?.data.items.length === 0"
class="w-full py-20 flex flex-col justify-center items-center gap-2"
>
<Icon class="text-7xl text-neutral-300 dark:text-neutral-700" name="i-tabler-photo-hexagon"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">
没有记录
</p>
</div>
<div v-else>
<div class="p-4">
<div class="relative grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3 fhd:grid-cols-5 gap-4">
<TransitionGroup
name="card"
@beforeLeave="beforeLeave"
@leave="leave"
>
<GBTaskCard
v-for="(v, i) in videoList?.data.items"
:key="v.task_id"
:video="v"
@delete="v => onCourseGreenDelete(v)"
/>
</TransitionGroup>
</div>
<div class="flex justify-end mt-4">
<UPagination v-model="page" :max="9" :page-count="pageCount" :total="videoList?.data.total || 0"/>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
</style>

View File

@@ -1,413 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import OptionBlock from '~/components/aigc/drawing/OptionBlock.vue';
import ResultBlock from '~/components/aigc/drawing/ResultBlock.vue';
import {useLoginState} from '~/composables/useLoginState';
import ModalAuthentication from '~/components/ModalAuthentication.vue';
import {type InferType, number, object, string} from 'yup';
import type {FormSubmitEvent} from '#ui/types';
import RatioSelector from '~/components/aigc/RatioSelector.vue';
import {useFetchWrapped} from '~/composables/useFetchWrapped';
import type {ResultBlockMeta} from '~/components/aigc/drawing';
import {useHistory} from '~/composables/useHistory';
import {del, set} from 'idb-keyval';
import ReferenceFigureSelector from '~/components/aigc/ReferenceFigureSelector.vue';
useSeoMeta({
title: '绘画',
})
const toast = useToast()
const modal = useModal()
const dayjs = useDayjs()
const history = useHistory()
const loginState = useLoginState()
const leftSection = ref<HTMLElement | null>(null)
const leftHandler = ref<HTMLElement | null>(null)
const showSidebar = ref(false)
const generating = ref(false)
const handle_stick_mousedown = (e: MouseEvent, min: number = 240, max: number = 400) => {
const handler = leftHandler.value
if (handler) {
const startX = e.clientX
const startWidth = handler.parentElement?.offsetWidth || 0
const handle_mousemove = (e: MouseEvent) => {
let newWidth = startWidth + e.clientX - startX
if (newWidth < min || newWidth > max) {
newWidth = Math.min(Math.max(newWidth, min), max)
}
handler.parentElement!.style.width = `${newWidth}px`
}
const handle_mouseup = () => {
leftSection.value?.classList.add('transition-all')
leftHandler.value?.lastElementChild?.classList.remove('bg-indigo-300', 'dark:bg-indigo-700', 'w-[3px]')
window.removeEventListener('mousemove', handle_mousemove)
window.removeEventListener('mouseup', handle_mouseup)
}
leftSection.value?.classList.remove('transition-all')
leftHandler.value?.lastElementChild?.classList.add('bg-indigo-300', 'dark:bg-indigo-700', 'w-[3px]')
window.addEventListener('mousemove', handle_mousemove)
window.addEventListener('mouseup', handle_mouseup)
}
}
const defaultRatios = [
{
ratio: '1:1',
value: '768:768',
},
{
ratio: '4:3',
value: '1024:768',
},
{
ratio: '3:4',
value: '768:1024',
},
]
interface StyleItem {
label: string
value: number
avatar?: { src: string }
}
const defaultStyles: StyleItem[] = [
{
label: '通用写实风格',
value: 401,
},
{
label: '日系动漫',
value: 201,
},
{
label: '科幻风格',
value: 114,
},
{
label: '怪兽风格',
value: 202,
},
{
label: '唯美古风',
value: 203,
},
{
label: '复古动漫',
value: 204,
},
{
label: '游戏卡通手绘',
value: 301,
},
{
label: '水墨画',
value: 101,
},
{
label: '概念艺术',
value: 102,
},
{
label: '水彩画',
value: 104,
},
{
label: '像素画',
value: 105,
},
{
label: '厚涂风格',
value: 106,
},
{
label: '插图',
value: 107,
},
{
label: '剪纸风格',
value: 108,
},
{
label: '印象派',
value: 119,
},
{
label: '印象派(莫奈)',
value: 109,
},
{
label: '油画',
value: 103,
},
{
label: '油画(梵高)',
value: 118,
},
{
label: '古典肖像画',
value: 111,
},
{
label: '黑白素描画',
value: 112,
},
{
label: '赛博朋克',
value: 113,
},
{
label: '暗黑风格',
value: 115,
},
{
label: '蒸汽波',
value: 117,
},
{
label: '2.5D',
value: 110,
},
{
label: '3D',
value: 116,
},
]
const img2imgStyles: StyleItem[] = [
{
label: '水彩画',
value: 106,
},
{
label: '2.5D',
value: 110,
},
{
label: '日系动漫',
value: 201,
},
{
label: '美系动漫',
value: 202,
},
{
label: '唯美古风',
value: 203,
},
]
const defaultFormSchema = object({
prompt: string().required('请输入提示词'),
negative_prompt: string(),
resolution: string().required('请选择分辨率'),
styles: object<StyleItem>({
label: string(),
value: number(),
}).required('请选择风格'),
file: string().nullable(),
})
type DefaultFormSchema = InferType<typeof defaultFormSchema>
const defaultFormState = reactive({
prompt: '',
negative_prompt: '',
resolution: '1024:768',
styles: defaultStyles.find(item => item.value === 401),
file: null,
})
watch(() => defaultFormState.file, (newVal) => {
if (newVal) {
defaultFormState.styles = img2imgStyles[0]
} else {
defaultFormState.styles = defaultStyles.find(item => item.value === 401)
}
})
const onDefaultFormSubmit = (event: FormSubmitEvent<DefaultFormSchema>) => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
generating.value = true
const styleItem = event.data.styles as StyleItem
if (!event.data.file) delete event.data.file
// generate a uuid
const fid = Math.random().toString(36).substring(2)
const meta: ResultBlockMeta = {
cost: '1000',
modal: '混元大模型',
style: styleItem.label,
ratio: event.data.resolution,
datetime: dayjs().unix(),
type: event.data.file ? '智能图生图' : '智能文生图',
}
history.text2img.unshift({
fid,
meta,
prompt: event.data.prompt,
})
useFetchWrapped<
(HunYuan.Text2Img.req | HunYuan.Img2Img.req) & AuthedRequest,
BaseResponse<HunYuan.resp>
>(event.data.file ? 'App.Assistant_HunYuan.TenImgToImg' : 'App.Assistant_HunYuan.TenTextToImg', {
token: loginState.token as string,
user_id: loginState.user.id,
device_id: 'web',
...event.data,
styles: styleItem.value,
}).then(res => {
if (res.ret !== 200) {
toast.add({
title: '生成失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
history.text2img = history.text2img.filter(item => item.fid !== fid)
return
}
history.text2img = history.text2img.map(item => {
if (item.fid === fid) {
set(`${item.fid}`, [`data:image/png;base64,${res.data.request_image}`])
item.meta = {
...item.meta,
id: res.data.data_id as string,
}
}
return item
})
}).catch(err => {
toast.add({
title: '生成失败',
description: err.msg || '网络错误',
color: 'red',
icon: 'i-tabler-circle-x',
})
}).finally(() => {
generating.value = false
})
}
</script> </script>
<template> <template>
<div class="w-full flex relative"> <div>
<div ref="leftSection" Homepage is still WIP
class="absolute -translate-x-full md:sticky md:translate-x-0 z-10 md:block h-[calc(100vh-4rem)] bg-neutral-200 dark:bg-neutral-800 transition-all"
style="width: 320px" :class="{'translate-x-0': showSidebar}">
<div ref="leftHandler"
class="absolute inset-0 left-auto hidden xl:flex flex-col justify-center items-center cursor-ew-resize px-1 group"
@dblclick="leftSection?.style.setProperty('width', '320px')"
@mousedown.prevent="handle_stick_mousedown">
<span
class="w-[1px] h-full bg-neutral-300 dark:bg-neutral-700 group-hover:bg-indigo-300 dark:group-hover:bg-indigo-700 group-hover:w-[3px] transition-all group-hover:delay-500 translate-x-1"></span>
</div>
<div
class="absolute bottom-28 -right-12 w-12 h-12 z-10 bg-neutral-100 dark:bg-neutral-900 rounded-r-lg shadow-lg flex md:hidden justify-center items-center">
<UButton color="black" icon="i-tabler-brush" size="lg" square @click="showSidebar = !showSidebar"></UButton>
</div>
<div class="h-full flex flex-col overflow-y-auto">
<UForm :schema="defaultFormSchema" :state="defaultFormState" @submit="onDefaultFormSubmit">
<div class="flex flex-col gap-2 p-4 pb-28">
<OptionBlock comment="Prompts" icon="i-tabler-article" label="提示词">
<UFormGroup name="prompt">
<UTextarea v-model="defaultFormState.prompt" :rows="2" autoresize
placeholder="请输入提示词,每个提示词之间用英文逗号隔开" resize/>
</UFormGroup>
</OptionBlock>
<OptionBlock comment="Negative Prompts" icon="i-tabler-article-off" label="负面提示词">
<UFormGroup name="negative_prompt">
<UTextarea v-model="defaultFormState.negative_prompt" :rows="2" autoresize
placeholder="请输入作品中不要出现的提示词,每个提示词之间用英文逗号隔开"
resize/>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-library-photo" label="参考图片">
<UFormGroup name="input_image">
<ReferenceFigureSelector
:value="defaultFormState.file"
@update="file => {defaultFormState.file = file}"
text="选择参考图片" text-on-select="已选择参考图"/>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-photo-hexagon" label="图片风格">
<UFormGroup name="styles">
<USelectMenu v-model="defaultFormState.styles"
:options="defaultFormState.file ? img2imgStyles : defaultStyles"></USelectMenu>
</UFormGroup>
</OptionBlock>
<OptionBlock icon="i-tabler-article-off" label="图片比例">
<UFormGroup name="resolution">
<RatioSelector v-model="defaultFormState.resolution" :ratios="defaultRatios"/>
</UFormGroup>
</OptionBlock>
</div>
<div class="absolute bottom-0 inset-x-0 flex flex-col items-center gap-2
bg-neutral-200 dark:bg-neutral-800 p-4 border-t border-neutral-400
dark:border-neutral-700">
<UButton type="submit" color="indigo" size="lg" class="font-bold" :loading="generating" block>
{{ generating ? '生成中' : '生成' }}
</UButton>
<p class="text-xs text-neutral-400 dark:text-neutral-500 font-bold">
生成即代表您同意<a href="#" target="_blank"
class="underline underline-offset-2">用户许可协议</a>
</p>
</div>
</UForm>
</div>
</div>
<ClientOnly>
<div class="flex-1 h-screen flex flex-col gap-4 bg-neutral-100 dark:bg-neutral-900 p-4 pb-20 overflow-y-auto">
<div v-if="!loginState.is_logged_in"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-user-circle" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">请登录后使用</p>
<UButton class="mt-2 font-bold" @click="modal.open(ModalAuthentication)" color="black" variant="solid"
size="xs">
登录
</UButton>
</div>
<div v-else-if="history.text2img.length === 0"
class="w-full h-full flex flex-col justify-center items-center gap-2 bg-neutral-100 dark:bg-neutral-900">
<Icon name="i-tabler-photo-hexagon" class="text-7xl text-neutral-300 dark:text-neutral-700"/>
<p class="text-sm text-neutral-500 dark:text-neutral-400">没有记录</p>
</div>
<ResultBlock v-else v-for="(result, k) in history.text2img" :fid="result.fid" :meta="result.meta"
:prompt="result.prompt" :key="result.fid"
@use-reference="file => {defaultFormState.file = file}">
<template #header-right>
<UPopover overlay>
<UButton color="black" size="xs" icon="i-tabler-trash" variant="ghost"></UButton>
<template #panel="{close}">
<div class="p-4 flex flex-col gap-4">
<h2 class="text-sm">删除后无法恢复,确定删除?</h2>
<div class="flex items-center justify-end gap-2">
<UButton color="gray" size="xs" class="font-bold" @click="close">
取消
</UButton>
<UButton color="red" size="xs" class="font-bold"
@click="() => {
history.text2img.splice(k, 1)
del(result.fid)
close()
}">
仍然删除
</UButton>
</div>
</div>
</template>
</UPopover>
</template>
</ResultBlock>
<div class="flex justify-center items-center gap-1 text-neutral-400 dark:text-neutral-600">
<UIcon name="i-tabler-info-triangle"/>
<p class="text-xs font-bold">所有图片均为 AI 生成服务器不会保存任何图像数据仅保存在浏览器本地</p>
</div>
</div>
</ClientOnly>
</div> </div>
</template> </template>

10942
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,12 @@ export default <Partial<Config>>{
sans: ['Rubik', 'Noto Sans SC', 'sans-serif'], sans: ['Rubik', 'Noto Sans SC', 'sans-serif'],
}, },
extend: { extend: {
screens: {
'hd': '1280px',
'fhd': '1920px',
'2k': '2560px',
'4k': '3840px',
},
fontSize: { fontSize: {
'2xs': '0.625rem', '2xs': '0.625rem',
}, },
@@ -55,5 +61,11 @@ export default <Partial<Config>>{
{ {
pattern: /^bg-/, pattern: /^bg-/,
}, },
{
pattern: /^from-/,
},
{
pattern: /^to-/,
},
], ],
} }

107
typings/types.d.ts vendored
View File

@@ -17,6 +17,12 @@ interface PagedData<T> {
items: T[] items: T[]
} }
interface PagedDataRequest {
page?: number
perpage?: number
to_user_id?: number
}
interface UserSchema { interface UserSchema {
id: number id: number
username: string username: string
@@ -28,6 +34,36 @@ interface UserSchema {
auth_code: 0 | 1 | 2 // 0: Banned, 1: User, 2: Operator auth_code: 0 | 1 | 2 // 0: Banned, 1: User, 2: Operator
} }
interface DigitalHumanItem {
user_id: number
create_time: number
model_id: number
avatar: string
type: number
name: string
description: string
id?: number
digital_human_id: number
}
interface GBVideoItem {
id: number
user_id: number
task_id: string
create_time: number
complete_time: number
progress: number
duration?: number
digital_human_id: number
title: string
content: string
bg_img: string
video_url?: string
video_cover?: string
subtitle?: string
speed: number
}
// Common request and response schemas // Common request and response schemas
namespace req { namespace req {
namespace user { namespace user {
@@ -49,6 +85,12 @@ namespace req {
mobile: string mobile: string
sms_code: string sms_code: string
} }
interface UserList {
page?: number
perpage?: number
is_verify: boolean
}
} }
namespace file { namespace file {
@@ -85,6 +127,47 @@ namespace req {
to_user_id: number to_user_id: number
task_id: string task_id: string
} }
/**
* @param sub_type 0为绿幕生成1为PPT生成
* @param sub_content BASE64后的ass字幕字符串
* @param sub_ver optional 字幕版本
*/
interface CourseSubtitleCreate {
sub_type: 0 | 1
task_id: string
sub_content: string
sub_ver?: number
}
interface DigitalHumanList {
to_user_id: number
page?: number
perpage?: number
}
/**
* @param title 任务标题筛选
*/
interface GBVideoList {
to_user_id: number
page?: number
perpage?: number
title?: string
}
interface GBVideoCreate {
device_id: string
digital_human_id: number
title: string
content: string
bg_img?: string
speed?: number
}
interface GBVideoDelete {
task_id: string
}
} }
interface AssistantTemplateList { interface AssistantTemplateList {
@@ -152,6 +235,30 @@ namespace resp {
interface CourseGenDelete { interface CourseGenDelete {
code: 0 | 1 code: 0 | 1
} }
/**
* @param video_sub_id 字幕记录 ID
* @param url 已上传的ass文件URL(文件存放于OSS)
*/
interface CourseSubtitleCreate {
video_sub_id: number
url: string
}
/**
* @param request_id 任务 ID同 task_id
*/
interface GBVideoCreate {
data_id: string
task_id: string
}
/**
* @param code 1: 删除成功, 0: 删除失败
*/
interface GBVideoDelete {
code: 0 | 1
}
} }
} }

8956
yarn.lock

File diff suppressed because it is too large Load Diff