1 Commits

Author SHA1 Message Date
f22e91ae78 chore(deps): update deps 2026-02-10 00:41:39 +08:00
64 changed files with 6780 additions and 5882 deletions

View File

@@ -11,9 +11,6 @@
"vueIndentScriptAndStyle": false,
"bracketSameLine": false,
"singleAttributePerLine": true,
"embeddedLanguageFormatting": "auto",
"experimentalSortPackageJson": true,
"experimentalSortImports": {},
"experimentalTailwindcss": {},
"experimentalSortPackageJson": false,
"ignorePatterns": []
}

View File

@@ -1,19 +1,23 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"eslint",
"unicorn",
"vue",
"typescript",
"jsdoc",
"promise",
"vitest"
],
"plugins": null,
"categories": {},
"rules": {
"no-unused-vars": "error"
},
"rules": {},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": [],
"version": null,
"componentWrapperFunctions": []
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,

View File

@@ -1,4 +1,5 @@
{
"oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
@@ -7,8 +8,5 @@
"typescript.tsdk": "node_modules\\typescript\\lib",
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}

View File

@@ -1,28 +1,14 @@
export default defineAppConfig({
ui: {
strategy: 'merge',
colors: {
primary: 'indigo',
neutral: 'neutral',
success: 'emerald',
warning: 'amber',
},
icons: {
loading: 'svg-spinners-180-ring',
},
gray: 'neutral',
strategy: 'merge',
button: {
slots: {
leadingIcon: 'animate-none',
icon: {
loading: 'animate-none',
},
},
input: {
slots: {
root: 'w-full',
},
},
textarea: {
slots: {
root: 'w-full',
default: {
loadingIcon: 'i-svg-spinners-180-ring-with-bg',
},
},
notifications: {

View File

@@ -4,7 +4,7 @@ import ModalAuthentication from '~/components/ModalAuthentication.vue'
const toast = useToast()
const route = useRoute()
const router = useRouter()
const overlay = useOverlay()
const modal = useModal()
const loginState = useLoginState()
useHead({
@@ -24,11 +24,10 @@ onMounted(() => {
toast.add({
title: '登录失效',
description: '登录已过期,请重新登录',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
const modal = overlay.create(ModalAuthentication)
modal.open()
modal.open(ModalAuthentication)
} else if (!loggedIn && !loginState.token) {
// Prevents redirect from register page
if (route.path === '/user/register') return
@@ -42,10 +41,12 @@ onMounted(() => {
<div>
<NuxtLoadingIndicator />
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
<UModals />
<USlideovers />
<UNotifications />
</div>
</template>

View File

@@ -1,33 +0,0 @@
@import 'tailwindcss';
@import '@nuxt/ui';
@config '../../../tailwind.config.ts';
@source inline("{hover:,}{bg,text,border,from,to}-{primary,neutral,amber}{-{50,{100..900..100},950},}{/{0..100..5},}");
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
html {
box-sizing: border-box;
}
}
@layer theme {
}

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
box-sizing: border-box;
}
}

View File

@@ -14,7 +14,7 @@ const props = defineProps({
},
bubbleColor: {
type: String,
default: 'primary',
default: 'primary-500',
},
})
</script>
@@ -30,7 +30,7 @@ const props = defineProps({
</h1>
<h1
class="text-xl font-bold text-neutral-700 dark:text-neutral-300 leading-none relative z-1"
class="text-xl font-bold text-neutral-700 dark:text-neutral-300 leading-none relative z-[1]"
>
{{ title }}
</h1>
@@ -43,7 +43,7 @@ const props = defineProps({
<div
v-if="bubble"
:class="`bg-${bubbleColor}/50`"
class="absolute -left-1.5 -bottom-1.5 w-4 h-4 rounded-full z-0"
class="absolute -left-1.5 -bottom-1.5 w-4 h-4 rounded-full z-[0]"
></div>
</div>
</template>

View File

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

View File

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

View File

@@ -18,12 +18,12 @@ const props = defineProps({
<template>
<div
:class="{
'w-full h-px': !vertical,
'w-px 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-linear-to-r rounded-full my-4"
class="bg-gradient-to-r rounded-full my-4"
></div>
</template>

View File

@@ -36,6 +36,6 @@ const size = computed(() => {
<style scoped>
.gradient-background {
@apply rounded-lg;
@apply bg-linear-to-r from-indigo-800 to-purple-600;
@apply bg-gradient-to-r from-indigo-800 to-purple-600;
}
</style>

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,8 @@ const emit = defineEmits({
})
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const isRealOpen = computed(() => props.isOpen)
const pagination = reactive({
page: 1,
@@ -48,7 +48,7 @@ const handleClose = () => {
if (props.isOpen) {
emit('close')
} else {
emit('close')
modal.close()
}
}
@@ -57,7 +57,7 @@ const handleSubmit = () => {
toast.add({
title: '请选择片头',
description: '请选择一个片头',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -69,12 +69,16 @@ const handleSubmit = () => {
<template>
<UModal
v-model:open="isRealOpen"
:ui="{ content: 'w-full sm:max-w-3xl' }"
:model-value="isOpen"
:ui="{ width: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<template #content>
<UCard>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3
@@ -84,7 +88,7 @@ const handleSubmit = () => {
</h3>
<UButton
class="-my-1"
color="neutral"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
@@ -93,7 +97,7 @@ const handleSubmit = () => {
</template>
<div>
<div class="grid w-full grid-cols-2 gap-4 sm:grid-cols-3">
<div class="w-full grid grid-cols-2 sm:grid-cols-3 gap-4">
<div
v-for="(titles, i) in userTitlesTemplate?.data.items"
:key="`user-titles-${titles.id}`"
@@ -102,31 +106,31 @@ const handleSubmit = () => {
'border-neutral-200 dark:border-neutral-700':
selectedTitle?.id !== titles.id,
}"
class="relative flex w-full cursor-pointer select-none flex-col items-center justify-center gap-2 overflow-hidden rounded-md border bg-white transition-all duration-150 dark:border-2 dark:bg-neutral-800"
class="relative flex flex-col justify-center items-center gap-2 overflow-hidden w-full bg-white dark:bg-neutral-800 rounded-md border dark:border-2 cursor-pointer transition-all duration-150 select-none"
@click="selectedTitle = titles"
>
<div
:class="{
'bg-primary-50': selectedTitle?.id === titles.id,
}"
class="relative aspect-video w-full overflow-hidden border-b bg-neutral-100 object-cover transition-all duration-150 dark:border-neutral-700 dark:bg-neutral-800"
class="relative bg-neutral-100 dark:bg-neutral-800 border-b dark:border-neutral-700 w-full aspect-video object-cover overflow-hidden transition-all duration-150"
>
<NuxtImg :src="titles.opening_url" />
<UIcon
v-if="selectedTitle?.id === titles.id"
class="text-primary absolute right-1 top-1 text-lg"
class="absolute top-1 right-1 text-lg text-primary"
name="i-tabler-check"
/>
</div>
<div class="flex w-full flex-col gap-1 px-2 pb-2">
<div class="flex items-center justify-between">
<div class="w-full flex flex-col gap-1 px-2 pb-2">
<div class="flex justify-between items-center">
<span
class="line-clamp-1 text-sm font-medium text-neutral-800 dark:text-neutral-300"
class="text-sm text-neutral-800 dark:text-neutral-300 font-medium line-clamp-1"
>
{{ titles.title }}
</span>
<span
class="text-xs font-medium text-neutral-300 dark:text-neutral-500"
class="text-xs text-neutral-300 dark:text-neutral-500 font-medium"
>
ID:{{ titles.id }}
</span>
@@ -137,7 +141,7 @@ const handleSubmit = () => {
<div class="flex justify-end">
<UPagination
v-if="(userTitlesTemplate?.data.total || 0) > 0"
v-model:page="pagination.page"
v-model="pagination.page"
:page-count="pagination.pageSize"
:total="userTitlesTemplate?.data.total || 0"
class="pt-4"
@@ -146,9 +150,9 @@ const handleSubmit = () => {
</div>
<template #footer>
<div class="flex items-center justify-between">
<div class="flex justify-between items-center">
<div>
<p class="select-none text-xs font-medium opacity-50">
<p class="text-xs font-medium opacity-50 select-none">
如果此处没有您的片头请在
<a
class="text-primary"
@@ -162,7 +166,7 @@ const handleSubmit = () => {
</div>
<div class="flex items-center gap-4">
<UButton
color="neutral"
color="gray"
label="取消"
variant="ghost"
@click="handleClose"
@@ -177,7 +181,6 @@ const handleSubmit = () => {
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@@ -5,8 +5,9 @@ import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success', 'close'])
const emit = defineEmits(['success'])
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
@@ -64,14 +65,14 @@ const onCreateCourseSubmit = async (
toast.add({
title: '未选择文件',
description: '请先选择 PPTX 文件',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
creationPending.value = true
// upload PPTX file
useFileGo(selected_file.value[0]!, 'ppt').then((url) => {
useFileGo(selected_file.value[0], 'ppt').then((url) => {
useFetchWrapped<
req.gen.CourseGenCreate & AuthedRequest,
BaseResponse<resp.gen.CourseGenCreate>
@@ -92,16 +93,16 @@ const onCreateCourseSubmit = async (
toast.add({
title: '创建成功',
description: '已加入生成队列',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
emit('success')
emit('close')
slide.close()
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -112,7 +113,7 @@ const onCreateCourseSubmit = async (
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -121,16 +122,14 @@ const onCreateCourseSubmit = async (
</script>
<template>
<USlideover
:dismissible="false"
title="新建微课视频"
>
<template #content>
<USlideover prevent-close>
<UCard
:ui="{
body: 'flex-1',
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-1 flex-col"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex items-center justify-between">
@@ -141,10 +140,10 @@ const onCreateCourseSubmit = async (
</h3>
<UButton
class="-my-1"
color="neutral"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="$emit('close')"
@click="slide.close()"
/>
</div>
</template>
@@ -157,7 +156,7 @@ const onCreateCourseSubmit = async (
@submit="onCreateCourseSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormField
<UFormGroup
label="微课标题"
name="task_title"
required
@@ -166,22 +165,22 @@ const onCreateCourseSubmit = async (
v-model="createCourseState.task_title"
placeholder="请输入微课标题"
/>
</UFormField>
</UFormGroup>
</div>
<div class="grid grid-cols-2 gap-2">
<UFormField
<UFormGroup
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
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="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
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"
@@ -193,7 +192,7 @@ const onCreateCourseSubmit = async (
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
@@ -207,18 +206,18 @@ const onCreateCourseSubmit = async (
</span>
</div>
</div>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="视频片头片尾"
name="opening"
>
<div
:class="{ 'shadow-inner': !!selected_titles }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isTitlesSelectorOpen = true"
>
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
>
<UIcon
v-if="!selected_titles"
@@ -230,7 +229,7 @@ const onCreateCourseSubmit = async (
:src="selected_titles?.opening_url"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span :class="!!selected_titles ? 'text-neutral-600' : ''">
{{ selected_titles?.title || '点击选择片头' }}
</span>
@@ -242,10 +241,23 @@ const onCreateCourseSubmit = async (
</span>
</div>
</div>
</UFormField>
</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>
<UFormField
<UFormGroup
label="PPT 文件"
required
>
@@ -256,24 +268,24 @@ const onCreateCourseSubmit = async (
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
@change="(file) => (selected_file = file)"
/>
</UFormField>
</UFormGroup>
<UAccordion
:items="[{ label: '高级选项' }]"
color="neutral"
color="gray"
size="lg"
>
<template #content>
<template #item>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<UFormField
<UFormGroup
label="生成线路"
name="gen_server"
>
<USelectMenu
v-model="createCourseState.gen_server"
:items="[
:options="[
{
label: '主线路',
value: 'main',
@@ -283,14 +295,15 @@ const onCreateCourseSubmit = async (
value: 'standby1',
},
]"
value-key="value"
option-attribute="label"
value-attribute="value"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<USlider
<URange
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
@@ -298,12 +311,32 @@ const onCreateCourseSubmit = async (
class="pt-4"
size="sm"
/>
</UFormField>
</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"
@@ -322,28 +355,6 @@ const onCreateCourseSubmit = async (
}
"
/>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="neutral"
label="取消"
size="lg"
variant="ghost"
@click="$emit('close')"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
</template>
</USlideover>
</template>

View File

@@ -4,7 +4,9 @@ import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success', 'close'])
const emit = defineEmits(['success'])
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
@@ -94,20 +96,20 @@ const onCreateCourseGreenSubmit = async (
BaseResponse<resp.gen.GBVideoCreate>
>('App.Digital_VideoTask.Create', payload)
.then((res) => {
if (res.data.task_id) {
if (!!res.data.task_id) {
toast.add({
title: '创建成功',
description: '视频已加入生成队列',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
emit('success')
emit('close')
slide.close()
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -118,7 +120,7 @@ const onCreateCourseGreenSubmit = async (
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -126,13 +128,14 @@ const onCreateCourseGreenSubmit = async (
</script>
<template>
<USlideover :dismissible="false">
<template #content>
<USlideover prevent-close>
<UCard
:ui="{
body: 'flex-1',
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-1 flex-col"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex items-center justify-between">
@@ -143,10 +146,10 @@ const onCreateCourseGreenSubmit = async (
</h3>
<UButton
class="-my-1"
color="neutral"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="emit('close')"
@click="slide.close()"
/>
</div>
</template>
@@ -159,7 +162,7 @@ const onCreateCourseGreenSubmit = async (
@submit="onCreateCourseGreenSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormField
<UFormGroup
label="视频标题"
name="title"
required
@@ -168,22 +171,22 @@ const onCreateCourseGreenSubmit = async (
v-model="createCourseState.title"
placeholder="请输入视频标题"
/>
</UFormField>
</UFormGroup>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<UFormField
<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 cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
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="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
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"
@@ -195,7 +198,7 @@ const onCreateCourseGreenSubmit = async (
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
@@ -209,14 +212,14 @@ const onCreateCourseGreenSubmit = async (
</span>
</div>
</div>
</UFormField>
</UFormGroup>
</div>
<!-- <UFormField label="背景图片" name="bg_img" help="可以上传图片作为视频背景,留空则为绿幕背景">
<!-- <UFormGroup label="背景图片" name="bg_img" help="可以上传图片作为视频背景,留空则为绿幕背景">
<UInput type="file" accept="image/jpg,image/png" placeholder="选择背景图片" @change="selected_bg_img = $event?.[0] || undefined"/>
</UFormField> -->
</UFormGroup> -->
<UFormField
<UFormGroup
label="驱动内容"
name="content"
required
@@ -227,30 +230,30 @@ const onCreateCourseGreenSubmit = async (
autoresize
placeholder="请输入驱动文本内容"
/>
</UFormField>
</UFormGroup>
<UFormField
<UFormGroup
label="启用背景合成"
name="bg_img"
help="开启后生成透明通道,可在视频生成完毕后选择自定义背景合成;关闭则使用绿幕背景。"
>
<USwitch v-model="enableBackgroundCompositing" />
</UFormField>
<UToggle v-model="enableBackgroundCompositing" />
</UFormGroup>
<UAccordion
:items="[{ label: '高级选项' }]"
color="gray"
size="lg"
>
<template #content>
<template #item>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<UFormField
<UFormGroup
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<USlider
<URange
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
@@ -258,7 +261,7 @@ const onCreateCourseGreenSubmit = async (
class="pt-4"
size="sm"
/>
</UFormField>
</UFormGroup>
</div>
</template>
</UAccordion>
@@ -267,11 +270,11 @@ const onCreateCourseGreenSubmit = async (
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="neutral"
color="gray"
label="取消"
size="lg"
variant="ghost"
@click="emit('close')"
@click="slide.close()"
/>
<UButton
:loading="creationPending"
@@ -293,7 +296,6 @@ const onCreateCourseGreenSubmit = async (
}
"
/>
</template>
</USlideover>
</template>

View File

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

View File

@@ -58,7 +58,7 @@ const handleFileInput = (event: { target: any }) => {
<template>
<div
class="w-full bg-neutral-200/50 dark:bg-neutral-700/50 rounded-md flex justify-between items-center p-1.5 gap-2 relative hover:bg-neutral-200/80 dark:hover:bg-neutral-700/80 transition border dark:border-neutral-700 cursor-pointer"
class="w-full bg-neutral-200/50 dark:bg-neutral-700/50 rounded-md flex justify-between items-center p-1.5 gap-2 relative hover:bg-neutral-200/80 hover:dark:bg-neutral-700/80 transition border dark:border-neutral-700 cursor-pointer"
:class="{ 'cursor-pointer': !loading, 'cursor-not-allowed': loading }"
@click="() => !loading && fileInput?.click()"
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ const handleBackgroundFileSelect = (event: Event) => {
toast.add({
title: '文件类型错误',
description: '请选择一个图片文件',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
@@ -82,7 +82,7 @@ const composeBackgroundVideo = async () => {
toast.add({
title: '未选择图片',
description: '请先选择一个背景图片',
color: 'warning',
color: 'orange',
icon: 'i-tabler-alert-circle',
})
return
@@ -110,7 +110,7 @@ const composeBackgroundVideo = async () => {
toast.add({
title: '合成成功',
description: '背景已成功合成,可预览或下载',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} catch (err: any) {
@@ -118,7 +118,7 @@ const composeBackgroundVideo = async () => {
toast.add({
title: '合成失败',
description: combinatorError.value,
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
@@ -172,7 +172,7 @@ const startDownload = (url: string, filename: string) => {
toast.add({
title: '下载完成',
description: '资源下载已完成',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
})
@@ -186,7 +186,7 @@ const startDownload = (url: string, filename: string) => {
toast.add({
title: '下载失败',
description: err.message || '下载失败,未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -197,14 +197,14 @@ const startDownload = (url: string, filename: string) => {
<template>
<div
class="hover:shadow-xs flex w-full gap-2 overflow-hidden rounded-xl border border-neutral-200 p-3 transition dark:border-neutral-700"
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="aspect-10/16 shadow-xs group relative flex h-48 flex-col items-center justify-center overflow-hidden rounded-lg"
class="flex-0 h-48 aspect-[10/16] flex flex-col items-center justify-center rounded-lg shadow overflow-hidden relative group"
>
<div
v-if="!video.video_cover"
class="flex h-full w-full flex-col items-center justify-center gap-2"
class="w-full h-full flex flex-col justify-center items-center gap-2"
:class="!isFailed ? 'bg-primary' : 'bg-rose-400'"
>
<UIcon
@@ -232,36 +232,36 @@ const startDownload = (url: string, filename: string) => {
<NuxtImg
v-else
:src="video.video_cover"
class="h-full w-full object-cover brightness-90"
class="w-full h-full brightness-90 object-cover"
/>
<div
class="absolute inset-0 flex items-center justify-center rounded-lg bg-black/10 opacity-0 backdrop-blur-md duration-300 group-hover:opacity-100"
class="absolute inset-0 bg-black/10 backdrop-blur-md flex justify-center items-center rounded-lg opacity-0 group-hover:opacity-100 duration-300"
>
<div
class="flex aspect-square w-14 cursor-pointer items-center justify-center rounded-full bg-gray-300/50 backdrop-blur-md"
class="rounded-full w-14 aspect-square bg-gray-300/50 backdrop-blur-md flex justify-center items-center cursor-pointer"
@click="isPreviewModalOpen = true"
>
<Icon
name="i-tabler-play"
class="text-3xl text-white"
class="text-white text-3xl"
/>
</div>
</div>
</div>
<div class="flex flex-1 flex-col justify-between gap-2">
<div class="flex-1 flex flex-col justify-between gap-2">
<div
class="flex-1 rounded-lg bg-neutral-100 p-2 px-2.5 dark:bg-neutral-800"
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="line-clamp-1 text-sm font-bold">
<p class="text-sm font-bold line-clamp-1">
{{ video.title || '无标题' }}
</p>
</li>
<li class="">
<h2 class="text-primary-500 text-2xs font-medium">完成时间</h2>
<p class="line-clamp-1 text-xs">
<h2 class="text-2xs font-medium text-primary-500">完成时间</h2>
<p class="text-xs line-clamp-1">
{{
video.complete_time
? dayjs(video.complete_time * 1000).format(
@@ -272,8 +272,8 @@ const startDownload = (url: string, filename: string) => {
</p>
</li>
<li class="">
<h2 class="text-primary-500 text-2xs font-medium">生成耗时</h2>
<p class="line-clamp-1 text-xs">
<h2 class="text-2xs font-medium text-primary-500">生成耗时</h2>
<p class="text-xs line-clamp-1">
{{
video.duration
? dayjs.duration(video.duration || 0).format('HH:mm:ss')
@@ -285,13 +285,13 @@ const startDownload = (url: string, filename: string) => {
class="col-span-2 cursor-pointer"
@click="isFullContentOpen = true"
>
<h2 class="text-primary-500 text-2xs font-medium">驱动文本</h2>
<p class="line-clamp-4 text-justify text-xs">{{ video.content }}</p>
<h2 class="text-2xs font-medium text-primary-500">驱动文本</h2>
<p class="text-xs line-clamp-4 text-justify">{{ video.content }}</p>
</li>
</ul>
</div>
<div
class="group flex flex-nowrap items-center justify-end whitespace-nowrap sm:justify-between"
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">-->
@@ -299,7 +299,7 @@ const startDownload = (url: string, filename: string) => {
<!-- <p class="text-xs">数字人 {{ video.digital_human_id }}</p>-->
<!-- </div>-->
<div
class="hidden w-fit items-center gap-1 transition-all group-hover:pointer-events-none group-hover:opacity-0 sm:flex"
class="w-fit hidden sm:flex items-center gap-1 transition-all group-hover:opacity-0 group-hover:pointer-events-none"
>
<p class="text-2xs text-neutral-400 dark:text-neutral-500">
{{ video.digital_human_id }}
@@ -307,14 +307,14 @@ const startDownload = (url: string, filename: string) => {
</div>
<div class="space-x-2">
<UButton
class="transition-all group-hover:pointer-events-auto group-hover:translate-x-0 group-hover:opacity-100 sm:pointer-events-none sm:translate-x-4 sm:opacity-0"
color="error"
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)"
/>
<UFieldGroup size="xs">
<UButtonGroup size="xs">
<UButton
:label="
downloadingState.subtitle > 0 && downloadingState.subtitle < 100
@@ -335,7 +335,7 @@ const startDownload = (url: string, filename: string) => {
)
"
/>
<UDropdownMenu
<UDropdown
:items="[
[
{
@@ -373,27 +373,31 @@ const startDownload = (url: string, filename: string) => {
leading-icon="i-tabler-download"
variant="soft"
/>
</UDropdownMenu>
</UFieldGroup>
</UDropdown>
</UButtonGroup>
</div>
</div>
</div>
<!-- Full video content -->
<UModal v-model:open="isFullContentOpen">
<template #content>
<UCard>
<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="text-primary block text-xs">驱动内容</span>
<span class="block text-xs text-primary">驱动内容</span>
</h3>
<UButton
color="neutral"
variant="ghost"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isFullContentOpen = false"
/>
</div>
@@ -416,64 +420,68 @@ const startDownload = (url: string, filename: string) => {
</div>
</template>
</UCard>
</template>
</UModal>
<UModal v-model:open="isPreviewModalOpen">
<template #content>
<UCard>
<UModal v-model="isPreviewModalOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>绿幕视频预览</p>
<p
class="w-full overflow-hidden text-ellipsis text-nowrap text-xs text-blue-500"
class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis"
>
{{ video.title }}
</p>
</div>
<UButton
class="-my-1"
color="neutral"
variant="ghost"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
<video
class="rounded-xs shadow-xs w-full"
class="w-full rounded shadow"
controls
autoplay
:src="video.video_url"
/>
</UCard>
</template>
</UModal>
<UModal v-model:open="isVideoBackgroundPreviewOpen">
<template #content>
<UCard>
<UModal v-model="isVideoBackgroundPreviewOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>视频背景合成</p>
<p
class="w-full overflow-hidden text-ellipsis text-nowrap text-xs text-blue-500"
class="text-xs text-blue-500 w-full overflow-hidden text-nowrap text-ellipsis"
>
{{ video.title }}
</p>
</div>
<UButton
class="-my-1"
color="neutral"
variant="ghost"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isVideoBackgroundPreviewOpen = false"
/>
</div>
@@ -483,7 +491,7 @@ const startDownload = (url: string, filename: string) => {
<!-- 背景图片选择区域 -->
<div
v-if="!compositedVideoBlob && !isCombinatorLoading"
class="rounded-lg border-2 border-dashed border-neutral-200 p-4 dark:border-neutral-700"
class="border-2 border-dashed border-neutral-200 dark:border-neutral-700 rounded-lg p-4"
>
<div class="space-y-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
@@ -531,7 +539,7 @@ const startDownload = (url: string, filename: string) => {
<!-- 错误提示 -->
<UAlert
v-if="combinatorError"
color="error"
color="red"
icon="i-tabler-alert-triangle"
title="合成失败"
:description="combinatorError"
@@ -542,7 +550,7 @@ const startDownload = (url: string, filename: string) => {
v-if="isCombinatorLoading"
class="space-y-2"
>
<div class="flex items-center justify-between">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ phaseText }}
</span>
@@ -562,7 +570,7 @@ const startDownload = (url: string, filename: string) => {
视频预览
</div>
<video
class="shadow-xs w-full rounded-lg bg-black"
class="w-full rounded-lg shadow bg-black"
controls
autoplay
muted
@@ -574,16 +582,14 @@ const startDownload = (url: string, filename: string) => {
<template #footer>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="outline"
color="gray"
label="取消"
:disabled="isCombinatorLoading"
@click="isVideoBackgroundPreviewOpen = false"
/>
<UButton
v-if="compositedVideoBlob"
color="neutral"
variant="outline"
color="gray"
label="重新选择"
@click="
() => {
@@ -597,7 +603,7 @@ const startDownload = (url: string, filename: string) => {
/>
<UButton
v-if="compositedVideoBlob"
color="success"
color="green"
icon="i-tabler-download"
label="下载合成视频"
@click="downloadCompositedVideo"
@@ -614,7 +620,6 @@ const startDownload = (url: string, filename: string) => {
</div>
</template>
</UCard>
</template>
</UModal>
</div>
</template>

View File

@@ -41,7 +41,7 @@ type subtitleStyleSchema = InferType<typeof subtitleStyleSchema>
const subtitleStyleState = reactive<subtitleStyleSchema>({
color: '#fff',
effect: 'shadow-xs',
effect: 'shadow',
fontSize: 24,
bottomOffset: 12,
})
@@ -58,7 +58,7 @@ const loadSrt = async () => {
toast.add({
title: '加载字幕失败',
description: `${err}` || '未知错误',
color: 'error',
color: 'red',
})
} finally {
isLoading.value = false
@@ -79,8 +79,8 @@ const parseSrt = (srt: string) => {
subtitles.value.push(subtitle)
}
subtitle = {
start: match[1] || '',
end: match[2] || '',
start: match[1],
end: match[2],
text: '',
}
} else if (subtitle) {
@@ -105,23 +105,23 @@ const generateSrt = () => {
const formatTime = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0]?.split(':') || []
const timeParts = parts[0].split(':')
return {
hours: parseInt(timeParts[0] || '0'),
minutes: parseInt(timeParts[1] || '0'),
seconds: parseInt(timeParts[2] || '0'),
milliseconds: parseInt(parts[1] || '0'),
hours: parseInt(timeParts[0]),
minutes: parseInt(timeParts[1]),
seconds: parseInt(timeParts[2]),
milliseconds: parseInt(parts[1]),
}
}
const formatTimeToDayjs = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0]?.split(':') || []
const timeParts = parts[0].split(':')
return dayjs()
.hour(parseInt(timeParts[0] || '0'))
.minute(parseInt(timeParts[1] || '0'))
.second(parseInt(timeParts[2] || '0'))
.millisecond(parseInt(parts[1] || '0'))
.hour(parseInt(timeParts[0]))
.minute(parseInt(timeParts[1]))
.second(parseInt(timeParts[2]))
.millisecond(parseInt(parts[1]))
}
const syncSubtitles = () => {
@@ -183,7 +183,7 @@ const saveNewSubtitle = () => {
.then((_) => {
modified.value = false
toast.add({
color: 'success',
color: 'green',
title: '字幕已保存',
description: '修改后的字幕文件已保存',
})
@@ -202,7 +202,7 @@ const exportVideo = async () => {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
@@ -213,7 +213,7 @@ const exportVideo = async () => {
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize,
textShadow:
subtitleStyleState.effect === 'shadow-xs'
subtitleStyleState.effect === 'shadow'
? {
offsetX: 2,
offsetY: 2,
@@ -258,27 +258,25 @@ defineExpose({
<template>
<div>
<USlideover
v-model:open="isDrawerActive"
:dismissible="!modified"
:ui="{
wrapper: 'max-w-lg',
body: 'flex flex-col flex-1 overflow-hidden',
}"
v-model="isDrawerActive"
:prevent-close="modified"
:ui="{ width: 'max-w-lg' }"
>
<template #content>
<UCard
class="flex flex-1 flex-col overflow-hidden"
class="flex flex-col flex-1 overflow-hidden"
:ui="{
body: 'overflow-auto flex-1',
body: { base: 'overflow-auto flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<UButton
color="neutral"
color="gray"
variant="ghost"
size="sm"
icon="tabler:x"
class="absolute end-5 top-5 z-10 flex sm:hidden"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isDrawerActive = false"
@@ -300,7 +298,7 @@ defineExpose({
<div
v-if="isLoading"
class="text-primary flex items-center justify-center"
class="flex justify-center items-center text-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -369,11 +367,11 @@ defineExpose({
</div>
<div
v-else
class="overshadow flex h-full flex-col gap-2 overflow-hidden overscroll-y-none"
class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow"
>
<div class="relative aspect-video w-full flex-1">
<div class="relative w-full aspect-video flex-1">
<div
class="subtitle absolute inset-x-0 mx-auto w-fit font-sans font-bold"
class="absolute w-fit mx-auto inset-x-0 font-sans font-bold subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
@@ -383,7 +381,7 @@ defineExpose({
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-xs'
subtitleStyleState.effect === 'shadow'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
@@ -393,7 +391,7 @@ defineExpose({
<video
controls
ref="videoElement"
class="rounded-xs"
class="rounded"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
@@ -404,20 +402,20 @@ defineExpose({
color="gray"
size="lg"
>
<template #content>
<template #item>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<div class="flex w-full flex-col justify-center">
<div class="w-full flex flex-col justify-center">
<div
class="relative aspect-video w-full overflow-hidden rounded-md"
class="rounded-md w-full aspect-video relative overflow-hidden"
>
<img
class="h-full w-full rounded-md object-cover"
class="object-cover w-full h-full rounded-md"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="subtitle absolute bottom-0 left-1/2 -translate-x-1/2 transform font-sans font-bold"
class="absolute font-sans font-bold bottom-0 left-1/2 transform -translate-x-1/2 subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
@@ -427,7 +425,7 @@ defineExpose({
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-xs'
subtitleStyleState.effect === 'shadow'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
@@ -435,7 +433,7 @@ defineExpose({
字幕样式预览
</span>
</div>
<span class="text-2xs font-medium italic opacity-50 mt-1">
<span class="text-sm italic opacity-50">
字幕预览仅供参考以实际渲染效果为准
</span>
</div>
@@ -445,14 +443,14 @@ defineExpose({
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormField
<UFormGroup
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
:options="[
{
label: '黑色',
value: '#000',
@@ -462,64 +460,66 @@ defineExpose({
value: '#fff',
},
]"
value-key="value"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.color"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
:options="[
{
label: '阴影',
value: 'shadow-xs',
value: 'shadow',
},
{
label: '描边',
value: 'stroke',
},
]"
value-key="value"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.effect"
/>
</UFormField>
</UFormGroup>
</div>
<UFormField
<UFormGroup
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<USlider
<URange
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<USlider
<URange
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormField>
</UFormGroup>
</UForm>
</div>
</template>
</UAccordion>
<ul
class="relative flex-1 space-y-0.5 overflow-y-auto scroll-smooth px-0.5 pb-[100%]"
class="flex-1 px-0.5 pb-[100%] overflow-y-auto space-y-0.5 scroll-smooth relative"
>
<li
v-for="(subtitle, index) in subtitles"
@@ -567,10 +567,10 @@ defineExpose({
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<div class="flex justify-end items-center gap-2">
<span
v-if="modified"
class="text-sm font-medium text-yellow-500"
class="text-sm text-yellow-500 font-medium"
>
已更改但未保存
</span>
@@ -593,14 +593,11 @@ defineExpose({
</div>
</template>
</UCard>
</template>
</USlideover>
</div>
</template>
<style scoped>
@reference '@/assets/css/main.css';
.overshadow {
@apply relative;
}
@@ -609,7 +606,7 @@ defineExpose({
content: '';
inset: 80% 0 0;
position: absolute;
@apply bg-linear-to-b pointer-events-none from-transparent to-white dark:to-neutral-950;
@apply bg-gradient-to-b from-transparent to-white dark:to-neutral-950 pointer-events-none;
}
.subtitle.stroke {

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ const handleClick = (e: any) => {
<template>
<button
class="w-fit flex justify-center items-center rounded-md font-bold border shadow-xs transition focus:ring-4"
class="w-fit flex justify-center items-center rounded-md font-bold border shadow-sm transition focus:ring-4"
:class="{
'w-full': block,
'uni-button--disabled': disabled || loading,

View File

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

View File

@@ -83,7 +83,7 @@ nuxtApp.vueApp.provide('uni-message', api)
<style scoped>
#message-provider .message-wrapper {
@apply z-50000 fixed inset-0 flex flex-col items-center pointer-events-none;
@apply z-[50000] fixed inset-0 flex flex-col items-center pointer-events-none;
}
.message-move,

View File

@@ -26,12 +26,12 @@ onMounted(() => {
<div
class="message"
:class="{
'text-blue-500! border-blue-400! bg-blue-50!': message.type === 'info',
'text-emerald-500! border-emerald-400! bg-emerald-50!':
'!text-blue-500 !border-blue-400 !bg-blue-50': message.type === 'info',
'!text-emerald-500 !border-emerald-400 !bg-emerald-50':
message.type === 'success',
'text-orange-500! border-orange-400! bg-orange-50!':
'!text-orange-500 !border-orange-400 !bg-orange-50':
message.type === 'warning',
'text-rose-500! border-rose-400! bg-rose-50!': message.type === 'error',
'!text-rose-500 !border-rose-400 !bg-rose-50': message.type === 'error',
[message.type]: message.type,
}"
>

View File

@@ -97,7 +97,7 @@ onMounted(() => {
ref="selectWrapperRef"
>
<button
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-neutral-200/50 dark:focus:ring-neutral-800/50 shadow-xs"
class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700 focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 shadow-sm"
:class="{
'cursor-not-allowed bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600':
disabled,
@@ -148,9 +148,9 @@ onMounted(() => {
v-for="(option, index) in items"
:key="index"
:class="{
'bg-neutral-200! dark:bg-neutral-700! hover:bg-neutral-200! dark:hover:bg-neutral-700!':
'!bg-neutral-200 dark:!bg-neutral-700 hover:!bg-neutral-200 dark:hover:!bg-neutral-700':
option.value === selectedItem?.value,
'cursor-not-allowed! text-neutral-300 dark:text-neutral-500 hover:bg-white dark:hover:bg-neutral-800!':
'!cursor-not-allowed text-neutral-300 dark:text-neutral-500 hover:bg-white dark:hover:!bg-neutral-800':
option.disabled,
}"
@click="!option.disabled ? handleOptionSelect(option) : void 0"

View File

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

View File

@@ -98,18 +98,18 @@ watch(
<template>
<button
class="relative flex items-center rounded-lg bg-neutral-100 dark:bg-neutral-800 shadow-inner transition ease-in-out group outline-hidden"
class="relative flex items-center rounded-lg bg-neutral-100 dark:bg-neutral-800 shadow-inner transition ease-in-out group outline-none"
:class="{
'bg-green-400! dark:bg-green-400/50!': checked,
'!bg-green-400 dark:!bg-green-400/50': checked,
[buttonSizeClass]: buttonSizeClass,
[buttonPaddingClass]: buttonPaddingClass,
}"
@click="handleCheck"
>
<span
class="aspect-1/1 translate-x-0 transition ease-in-out bg-white dark:bg-black rounded-md shadow-xs duration-300 group-active:scale-90"
class="aspect-[1/1] translate-x-0 transition ease-in-out bg-white dark:bg-black rounded-md shadow duration-300 group-active:scale-90"
:class="{
'shadow-lg!': checked,
'!shadow-lg': checked,
'group-active:translate-x-3 group-active:duration-500': !checked,
[bulletSizeClass]: bulletSizeClass,
[bulletTranslateClass]: checked,

View File

@@ -73,7 +73,7 @@ export const useLoginState = defineStore(
persist: {
key: 'xsh_assistant_persisted_state',
storage: piniaPluginPersistedstate.localStorage(),
pick: ['is_logged_in', 'token', 'user'],
paths: ['is_logged_in', 'token', 'user'],
},
}
)

View File

@@ -33,7 +33,7 @@ export const useTourState = defineStore(
persist: {
key: 'xsh_assistant_tour_state',
storage: piniaPluginPersistedstate.localStorage(),
pick: ['tourState'],
paths: ['tourState'],
},
}
)

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import ModalAuthentication from '~/components/ModalAuthentication.vue'
const colorMode = useColorMode()
const dayjs = useDayjs()
const overlay = useOverlay()
const modal = useModal()
const toast = useToast()
const router = useRouter()
const loginState = useLoginState()
@@ -41,9 +40,7 @@ const links = [
},
]
const items: (DropdownMenuItem & {
icon?: string
})[][] = [
const items = [
[
{
label: 'support@fenshenzhike.com',
@@ -67,7 +64,7 @@ const items: (DropdownMenuItem & {
toast.add({
title: '退出登录',
description: `您已成功退出登录账号`,
color: 'success',
color: 'indigo',
icon: 'i-tabler-logout-2',
})
router.push({ path: '/user/authenticate' })
@@ -77,8 +74,7 @@ const items: (DropdownMenuItem & {
]
const open_login_modal = () => {
const modal = overlay.create(ModalAuthentication)
modal.open()
modal.open(ModalAuthentication)
}
onMounted(async () => {
@@ -101,22 +97,22 @@ onMounted(async () => {
toast.add({
title: '获取待审核用户失败',
description: '获取未审核用户列表失败',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} else if (unverifiedUsers.data.total > 0) {
toast.add({
title: '有新用户等待审核',
description: `${unverifiedUsers.data.total} 个新用户注册,请尽快审核`,
color: 'warning',
color: 'amber',
icon: 'i-tabler-user-plus',
duration: 0,
timeout: 0,
actions: [
{
label: '前往处理',
variant: 'solid',
color: 'warning',
onClick: () => {
color: 'amber',
click: () => {
router.push({
path: '/generation/admin/users',
query: {
@@ -127,7 +123,7 @@ onMounted(async () => {
},
// {
// label: '全部忽略',
// onClick: () => {
// click: () => {
// alert('ignored')
// },
// },
@@ -139,17 +135,16 @@ onMounted(async () => {
</script>
<template>
<div class="relative grid min-h-screen w-full">
<div class="relative grid w-full min-h-screen">
<header>
<h1 class="inline-flex flex-col">
<span class="text-lg font-bold text-neutral-600 dark:text-neutral-300">
<span class="text-lg text-neutral-600 dark:text-neutral-300 font-bold">
AIGC 微课视频研创平台
</span>
<!-- <span class="text-xs text-neutral-600 dark:text-neutral-300">眩生花科技</span> -->
</h1>
<!-- <div class="hidden md:block">
<NavigationMenu
orientation="horizontal"
<UHorizontalNavigation
:links="links"
class="select-none"
/>
@@ -162,7 +157,7 @@ onMounted(async () => {
? 'i-line-md-sunny-outline-to-moon-alt-loop-transition'
: 'i-line-md-moon-alt-to-sunny-outline-loop-transition'
"
color="neutral"
color="gray"
variant="ghost"
aria-label="Theme"
@click="isDark = !isDark"
@@ -172,24 +167,21 @@ onMounted(async () => {
label="登录或注册"
size="xs"
class="font-bold"
color="primary"
color="indigo"
@click="open_login_modal"
/>
<UDropdownMenu
<UDropdown
v-if="loginState.is_logged_in"
:items="items"
:content="{
align: 'end',
}"
:popper="{ placement: 'bottom-start' }"
:ui="{ item: { disabled: 'cursor-text select-text' } }"
>
<UAvatar
:alt="loginState.user.username.toUpperCase()"
:src="loginState.user.avatar"
:chip="{
color: 'warning',
position: 'bottom-right',
text: 'OP',
}"
chip-color="amber"
chip-position="bottom-right"
chip-text="OP"
size="md"
/>
<template #account="{ item }">
@@ -198,7 +190,7 @@ onMounted(async () => {
已登录为
<UBadge
v-if="loginState.user.auth_code === 2"
color="warning"
color="amber"
size="xs"
variant="subtle"
>
@@ -206,7 +198,7 @@ onMounted(async () => {
</UBadge>
</p>
<p
class="max-w-40 truncate whitespace-nowrap font-medium text-gray-900 dark:text-white"
class="truncate whitespace-nowrap max-w-40 font-medium text-gray-900 dark:text-white"
>
{{ loginState.user?.username }}
</p>
@@ -215,12 +207,11 @@ onMounted(async () => {
<template #item="{ item }">
<span class="truncate">{{ item.label }}</span>
<UIcon
v-if="item.icon"
:name="item.icon"
class="ms-auto size-4 shrink-0 text-gray-400 dark:text-gray-500"
class="flex-shrink-0 h-4 w-4 text-gray-400 dark:text-gray-500 ms-auto"
/>
</template>
</UDropdownMenu>
</UDropdown>
</ClientOnly>
</div>
</header>
@@ -238,10 +229,8 @@ onMounted(async () => {
</template>
<style>
@reference '@/assets/css/main.css';
body {
@apply bg-neutral-50 bg-fixed dark:bg-neutral-950;
@apply bg-neutral-50 dark:bg-neutral-950 bg-fixed;
/* @apply bg-[url('~/assets/background-pattern.svg')] dark:bg-[url('~/assets/background-pattern-dark.svg')]; */
}
@@ -281,13 +270,11 @@ body {
</style>
<style scoped>
@reference '@/assets/css/main.css';
header {
@apply fixed inset-x-0 z-30 h-16 border-b bg-white/50;
@apply dark:border-neutral-800 dark:bg-neutral-900/50;
@apply fixed inset-x-0 h-16 bg-white border-b z-30;
@apply dark:bg-neutral-900 dark:border-neutral-800;
@apply flex flex-row items-center justify-between px-4;
@apply backdrop-blur-3xl backdrop-saturate-150;
@apply bg-opacity-50 dark:bg-opacity-50 backdrop-blur-3xl backdrop-saturate-150;
}
main {
@@ -295,8 +282,8 @@ main {
}
footer {
@apply z-30 h-16 border-t bg-white;
@apply dark:border-neutral-800 dark:bg-neutral-900;
@apply h-16 bg-white border-t z-30;
@apply dark:bg-neutral-900 dark:border-neutral-800;
@apply flex flex-row items-center justify-between px-4;
}
</style>

View File

@@ -0,0 +1,554 @@
<script lang="ts" setup>
import ChatItem from '~/components/aigc/chat/ChatItem.vue'
import Message from '~/components/aigc/chat/Message.vue'
import {
type Assistant,
type ChatMessage,
type ChatMessageId,
type ChatSession,
type ChatSessionId,
llmModels,
type ModelTag,
} from '~/typings/llm'
import { useHistory } from '~/composables/useHistory'
import { uuidv4 } from '@uniiem/uuid'
import { useLLM } from '~/composables/useLLM'
import { trimObject } from '@uniiem/object-trim'
import ModalAuthentication from '~/components/ModalAuthentication.vue'
import NewSessionScreen from '~/components/aigc/chat/NewSessionScreen.vue'
useSeoMeta({
title: '聊天',
})
const dayjs = useDayjs()
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const historyStore = useHistory()
const { chatSessions } = storeToRefs(historyStore)
const { setChatSessions } = historyStore
const currentSessionId = ref<ChatSessionId | null>(null)
const messagesWrapperRef = ref<HTMLDivElement | null>(null)
const showSidebar = ref(false)
const user_input = ref('')
const responding = ref(false)
const currentModel = ref<ModelTag>('spark3_5')
const currentAssistant = computed<Assistant | null>(
() => getSessionCopyById(currentSessionId.value || '')?.assistant || null
)
const modals = reactive({
modelSelect: false,
assistantSelect: false,
newSessionScreen: false,
})
/**
* 获取指定 ID 的会话数据
* @param chatSessionId
*/
const getSessionCopyById = (
chatSessionId: ChatSessionId
): ChatSession | undefined =>
chatSessions.value.find((s) => s.id === chatSessionId)
/**
* 切换当前会话
* @param chatSessionId 指定会话 ID不传则切换到列表中第一个会话
*/
const selectCurrentSessionId = (chatSessionId?: ChatSessionId) => {
if (chatSessions.value.length > 0) {
if (chatSessionId) {
// 切换到指定 ID
// 保存当前输入并清空输入框
setChatSessions(
chatSessions.value.map((s) =>
s.id === currentSessionId.value
? {
...s,
last_input: user_input.value,
}
: s
)
)
user_input.value = ''
// 切换到指定 ID 会话
currentSessionId.value = chatSessionId
// 恢复输入
user_input.value = getSessionCopyById(chatSessionId)?.last_input || ''
// 清除已恢复的输入
setChatSessions(
chatSessions.value.map((s) =>
s.id === chatSessionId
? {
...s,
last_input: '',
}
: s
)
)
} else {
// 切换到第一个会话
currentSessionId.value = chatSessions.value[0].id
}
} else {
handleClickCreateSession()
}
nextTick(() => {
showSidebar.value = false
modals.newSessionScreen = false
scrollToMessageListBottom()
})
}
/**
* 创建新会话处理函数
* @param assistant 指定助手,不传或空值则不指定助手
*/
const createSession = (assistant: Assistant | null) => {
// 生成一个新的会话 ID
const sessionId = uuidv4()
// 新会话数据
const newChat = !!assistant
? {
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
assistant,
}
: {
id: sessionId,
subject: '新对话',
messages: [],
create_at: dayjs().unix(),
}
// 插入新会话数据
setChatSessions([newChat, ...chatSessions.value])
// 切换到新的会话
selectCurrentSessionId(sessionId)
// 关闭新建会话屏幕
modals.newSessionScreen = false
nextTick(() => {
if (!!currentAssistant.value) {
insetMessage({
id: uuidv4(),
role: 'system',
content: currentAssistant.value?.role || '',
preset: true,
})
insetMessage({
id: uuidv4(),
role: 'user',
content: `${currentAssistant.value?.target}${currentAssistant.value?.demand}`,
preset: true,
})
insetMessage({
id: uuidv4(),
role: 'assistant',
content: currentAssistant.value?.input_tpl || '',
preset: true,
})
} else {
insetMessage({
id: uuidv4(),
role: 'assistant',
content: '你好,有什么可以帮助你的吗?',
preset: true,
})
}
})
}
/**
* 处理点击新建会话按钮事件
*/
const handleClickCreateSession = () => {
showSidebar.value = false
modals.newSessionScreen = true
}
/**
* 处理发送消息操作
* @param event
*/
const handleClickSend = (event: any) => {
if (!loginState.is_logged_in) {
modal.open(ModalAuthentication)
return
}
if (event.ctrlKey) {
return
}
if (responding.value) return
if (!user_input.value) return
if (!currentSessionId.value) {
toast.add({
title: '发送失败',
description: '请先选择一个会话',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
}
// 插入用户消息
insetMessage({
id: uuidv4(),
role: 'user',
content: user_input.value,
create_at: dayjs().unix(),
})
user_input.value = ''
// 进入响应中状态
responding.value = true
// 插入空助手消息(加载状态)
const assistantReplyId = insetMessage({
id: uuidv4(),
role: 'assistant',
content: '',
})
// 请求模型回复
const trimmedMessages = trimObject(getMessages(), 2000, {
keys: ['content'],
})
useLLM(trimmedMessages, {
modelTag: currentModel.value,
})
.then((reply) => {
modifyMessageContent(assistantReplyId, reply)
})
.catch((err) => {
modifyMessageContent(assistantReplyId, err, true)
})
.finally(() => {
responding.value = false
})
}
const scrollToMessageListBottom = () => {
nextTick(() => {
messagesWrapperRef.value?.scrollTo({
top: messagesWrapperRef.value.scrollHeight,
behavior: 'smooth',
})
})
}
const insetMessage = (message: ChatMessage): ChatMessageId => {
setChatSessions(
chatSessions.value.map((s) =>
s.id === currentSessionId.value
? {
...s,
messages: [...s.messages, message],
}
: s
)
)
scrollToMessageListBottom()
return message.id
}
const getMessages = () =>
getSessionCopyById(currentSessionId.value!)?.messages || []
const modifyMessageContent = (
messageId: ChatMessageId,
content: string,
interrupted: boolean = false,
updateTime: boolean = true
) => {
setChatSessions(
chatSessions.value.map((s) =>
s.id === currentSessionId.value
? {
...s,
messages: s.messages.map((m) =>
m.id === messageId
? {
...m,
content,
interrupted,
create_at: updateTime ? dayjs().unix() : m.create_at,
}
: m
),
}
: s
)
)
scrollToMessageListBottom()
}
onMounted(() => {
// 切换到第一个会话, 没有会话会自动创建
selectCurrentSessionId()
})
</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] shadow-sidebar border-r border-transparent dark:border-neutral-700 transition-all duration-300 ease-out"
:class="{ 'translate-x-0': showSidebar }"
>
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
<!-- list -->
<div class="flex flex-col gap-3 relative">
<!-- ClientOnly avoids hydrate exception -->
<ClientOnly>
<TransitionGroup name="chat-item">
<div v-if="chatSessions.length === 0">
<div
class="text-center text-neutral-400 dark:text-neutral-500 py-4 flex flex-col items-center gap-2"
>
<Icon
name="i-tabler-messages"
class="text-2xl"
/>
<span>没有会话</span>
</div>
</div>
<ChatItem
v-for="session in chatSessions"
:chat-session="session"
:key="session.id"
:active="session.id === currentSessionId"
@click="selectCurrentSessionId(session.id)"
@remove="
() => {
chatSessions.splice(
chatSessions.findIndex((s) => s.id === session.id),
1
)
session.id === currentSessionId && selectCurrentSessionId()
}
"
/>
</TransitionGroup>
</ClientOnly>
</div>
</div>
<div class="pt-4 flex justify-between items-center">
<div></div>
<div>
<UButton
color="white"
variant="solid"
icon="i-tabler-message-circle-plus"
@click="handleClickCreateSession"
>
新建聊天
</UButton>
</div>
</div>
</div>
<div class="h-[calc(100vh-4rem)] flex-1 bg-white dark:bg-neutral-900">
<Transition
name="message"
mode="out-in"
>
<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>
<NewSessionScreen
v-else-if="
modals.newSessionScreen ||
getSessionCopyById(currentSessionId!) === undefined
"
:non-back="!getSessionCopyById(currentSessionId!)"
@select="createSession"
@cancel="modals.newSessionScreen = false"
/>
<div
v-else
class="w-full h-full flex flex-col"
>
<div
class="w-full p-4 bg-neutral-50 dark:bg-neutral-800/50 border-b dark:border-neutral-700/50 flex items-center gap-2"
>
<UButton
class="md:hidden"
color="black"
variant="ghost"
icon="i-tabler-menu-2"
@click="showSidebar = !showSidebar"
></UButton>
<h1 class="font-medium">
{{ getSessionCopyById(currentSessionId!)?.subject || '新对话' }}
</h1>
</div>
<div
ref="messagesWrapperRef"
class="flex-1 flex flex-col overflow-auto overflow-x-hidden"
>
<div class="flex flex-col gap-8 px-4 py-8">
<TransitionGroup name="message">
<Message
v-for="message in getMessages() || []"
:message="message"
:key="message.id"
/>
</TransitionGroup>
</div>
</div>
<ClientOnly>
<div
class="w-full p-4 pt-2 flex flex-col gap-2 bg-neutral-50 dark:bg-neutral-800/50 border-t dark:border-neutral-700/50"
>
<div
class="flex items-center gap-2 overflow-auto overflow-y-hidden"
>
<button
class="chat-option-btn"
@click="modals.modelSelect = true"
>
<Icon name="tabler:box" />
<span class="text-xs">
{{
llmModels
.find((m) => m.tag === currentModel)
?.name.toUpperCase() || '模型'
}}
</span>
</button>
<button
v-if="currentAssistant?.tpl_name"
class="chat-option-btn"
>
<Icon name="tabler:robot-face" />
<span class="text-xs">
{{ currentAssistant.tpl_name }}
</span>
</button>
</div>
<div class="relative">
<UTextarea
v-model="user_input"
size="lg"
autoresize
:rows="5"
:maxrows="12"
class="font-sans"
placeholder="Enter 发送, Ctrl + Enter 换行"
@keydown.ctrl.enter="user_input += '\n'"
@keydown.enter.prevent="handleClickSend"
/>
<UButton
color="black"
variant="solid"
icon="i-tabler-send-2"
class="absolute bottom-2.5 right-3"
@click.stop="handleClickSend"
>
发送
</UButton>
</div>
</div>
</ClientOnly>
</div>
</Transition>
</div>
<!-- Modals -->
<UModal v-model="modals.modelSelect">
<UCard>
<template #header>
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
选择大语言模型
</h3>
</template>
<div class="grid grid-cols-3 gap-4">
<div
v-for="(llm, index) in llmModels"
:key="index"
@click="currentModel = llm.tag"
class="flex flex-col gap-2 justify-center items-center w-full aspect-[1/1] border-2 rounded-xl cursor-pointer transition duration-150 select-none"
:class="
llm.tag === currentModel
? 'border-primary shadow-xl bg-primary text-white'
: 'dark:border-neutral-800 bg-white dark:bg-black shadow-card'
"
>
<Icon
v-if="llm?.icon"
:name="llm.icon"
class="text-4xl opacity-80"
/>
<div class="flex flex-col gap-0.5 items-center">
<h1 class="font-bold drop-shadow opacity-90">
{{ llm.name || 'unknown' }}
</h1>
<p class="text-xs opacity-60">{{ llm.description }}</p>
</div>
</div>
</div>
<template #footer>
<div
class="flex justify-end items-center"
@click="modals.modelSelect = false"
>
<UButton>确定</UButton>
</div>
</template>
</UCard>
</UModal>
</div>
</template>
<!--suppress CssUnusedSymbol -->
<style scoped>
.message-enter-active {
@apply transition duration-300;
}
.message-enter-from {
@apply translate-y-4 opacity-0;
}
.chat-item-move,
.chat-item-enter-active,
.chat-item-leave-active {
@apply transition-all duration-300;
}
.chat-item-enter-from,
.chat-item-leave-to {
@apply opacity-0 scale-90;
}
.chat-item-leave-active {
@apply absolute inset-x-0;
}
.chat-option-btn {
@apply text-lg px-2 py-1.5 flex gap-1 justify-center items-center rounded-lg;
@apply bg-white border border-neutral-300 shadow-sm hover:shadow-card;
@apply dark:bg-neutral-800 dark:border-neutral-600;
}
</style>

View File

@@ -0,0 +1,557 @@
<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

@@ -200,7 +200,7 @@ const open = (url?: string | URL, target?: string, features?: string) => {
>
<div class="container max-w-[1280px] mx-auto py-4 space-y-12">
<div
class="pattern w-full p-10 flex flex-col justify-center gap-3 items-center rounded-lg shadow-xs border border-gray-200 dark:border-neutral-700"
class="pattern w-full p-10 flex flex-col justify-center gap-3 items-center rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700"
>
<h1 class="text-4xl font-bold text-center text-primary">AI 工具导航</h1>
<p>常用 AI 工具一网打尽常用常新</p>
@@ -222,7 +222,7 @@ const open = (url?: string | URL, target?: string, features?: string) => {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="link in cat.links"
class="bg-white dark:bg-neutral-800 p-4 rounded-lg shadow-xs border border-gray-200 dark:border-neutral-700 space-y-2 cursor-pointer hover:shadow-md transition-all duration-300"
class="bg-white dark:bg-neutral-800 p-4 rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700 space-y-2 cursor-pointer hover:shadow-md transition-all duration-300"
:key="link.id"
@click="open(link.url)"
>

View File

@@ -58,11 +58,11 @@ onMounted(() => {
</script>
<template>
<div class="relative flex w-full">
<div class="w-full flex relative">
<div
class="absolute z-10 flex h-[calc(100vh-4rem)] w-full -translate-x-full flex-col border-r border-neutral-200 bg-neutral-100 p-4 transition-all duration-300 ease-out md:sticky md:w-[300px] md:translate-x-0 dark:border-neutral-700 dark:bg-neutral-900"
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-1 flex-col overflow-auto overflow-x-hidden">
<div class="flex flex-col flex-1 overflow-auto overflow-x-hidden">
<div class="flex flex-col gap-1">
<ClientOnly>
<NavItem
@@ -100,8 +100,6 @@ onMounted(() => {
</template>
<style>
@reference '@/assets/css/main.css';
.subpage-enter-active,
.subpage-leave-active {
@apply transition-all duration-300;
@@ -109,7 +107,7 @@ onMounted(() => {
.subpage-enter-from,
.subpage-leave-to {
@apply translate-x-4 opacity-0;
@apply opacity-0 translate-x-4;
}
.loading-screen-leave-active {

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { object, string, number } from 'yup'
import type { FormSubmitEvent, TableColumn } from '#ui/types'
import type { FormSubmitEvent } from '#ui/types'
useHead({
title: '数字人定制管理 | 管理员',
@@ -40,38 +40,38 @@ const {
const trainList = computed(() => trainListResp.value?.data.items || [])
// 表格列定义
const columns: TableColumn<DigitalHumanTrainItem>[] = [
const columns = [
{
accessorKey: 'id',
header: 'ID',
key: 'id',
label: 'ID',
},
{
accessorKey: 'dh_name',
header: '数字人名称',
key: 'dh_name',
label: '数字人名称',
},
{
accessorKey: 'organization',
header: '单位名称',
key: 'organization',
label: '单位名称',
},
{
accessorKey: 'user_id',
header: '用户ID',
key: 'user_id',
label: '用户ID',
},
{
accessorKey: 'create_time',
header: '创建时间',
key: 'create_time',
label: '创建时间',
},
{
accessorKey: 'video_url',
header: '数字人视频',
key: 'video_url',
label: '数字人视频',
},
{
accessorKey: 'auth_video_url',
header: '授权视频',
key: 'auth_video_url',
label: '授权视频',
},
{
accessorKey: 'actions',
header: '操作',
key: 'actions',
label: '操作',
},
]
@@ -126,7 +126,7 @@ const handleAvatarUpload = (files: FileList) => {
toast.add({
title: '文件格式错误',
description: '请上传图片文件',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
@@ -137,7 +137,7 @@ const handleAvatarUpload = (files: FileList) => {
toast.add({
title: '文件过大',
description: '图片文件大小不能超过10MB',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
@@ -155,7 +155,7 @@ const onProcessSubmit = async (
if (!avatarFile.value) {
toast.add({
title: '请上传数字人预览图',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
@@ -225,7 +225,7 @@ const onProcessSubmit = async (
? `,失败 ${createUserResult.data.failed}`
: ''
}`,
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
@@ -250,7 +250,7 @@ const onProcessSubmit = async (
toast.add({
title: '录入失败',
description: errorMessage,
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
@@ -274,7 +274,7 @@ const handleDeleteTrain = async (item: DigitalHumanTrainItem) => {
toast.add({
title: '删除成功',
description: '定制记录已删除',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
await refreshTrainList()
@@ -288,7 +288,7 @@ const handleDeleteTrain = async (item: DigitalHumanTrainItem) => {
toast.add({
title: '删除失败',
description: errorMessage,
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -304,7 +304,7 @@ const previewVideo = (videoUrl: string, title: string) => {
// 创建一个简单的视频预览弹窗
const videoModal = document.createElement('div')
videoModal.className =
'fixed inset-0 z-50 flex items-center justify-center bg-black/50'
'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'
const videoContainer = document.createElement('div')
videoContainer.className =
@@ -323,7 +323,7 @@ const previewVideo = (videoUrl: string, title: string) => {
const closeButton = document.createElement('button')
closeButton.textContent = '关闭'
closeButton.className =
'mt-4 px-4 py-2 bg-gray-500 text-white rounded-xs hover:bg-gray-600'
'mt-4 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600'
closeButton.onclick = () => {
document.body.removeChild(videoModal)
}
@@ -352,11 +352,11 @@ const previewVideo = (videoUrl: string, title: string) => {
>
<template #action>
<UButton
color="neutral"
color="gray"
variant="soft"
icon="i-tabler-refresh"
label="刷新"
@click="() => refreshTrainList()"
@click="refreshTrainList"
/>
</template>
</BubbleTitle>
@@ -374,80 +374,77 @@ const previewVideo = (videoUrl: string, title: string) => {
<div class="flex flex-col gap-4">
<UTable
:data="trainList"
:rows="trainList"
:columns="columns"
:loading="trainListStatus === 'pending'"
loading-color="warning"
loading-animation="carousel"
class="rounded-md border dark:border-neutral-800"
:progress="{ color: 'amber', animation: 'carousel' }"
class="border dark:border-neutral-800 rounded-md"
>
<template #create_time-cell="{ row }">
<span class="text-sm">
{{ formatTime(row.original.create_time) }}
</span>
<template #create_time-data="{ row }">
<span class="text-sm">{{ formatTime(row.create_time) }}</span>
</template>
<template #video_url-cell="{ row }">
<template #video_url-data="{ row }">
<div class="flex gap-2">
<UButton
color="info"
color="blue"
variant="soft"
size="xs"
icon="i-tabler-download"
:to="row.original.video_url"
:to="row.video_url"
target="_blank"
label="下载"
/>
<UButton
color="success"
color="green"
variant="soft"
size="xs"
icon="i-tabler-eye"
label="预览"
@click="previewVideo(row.original.video_url, '数字人视频')"
@click="previewVideo(row.video_url, '数字人视频')"
/>
</div>
</template>
<template #auth_video_url-cell="{ row }">
<template #auth_video_url-data="{ row }">
<div class="flex gap-2">
<UButton
color="info"
color="blue"
variant="soft"
size="xs"
icon="i-tabler-download"
:to="row.original.auth_video_url"
:to="row.auth_video_url"
target="_blank"
label="下载"
/>
<UButton
color="success"
color="green"
variant="soft"
size="xs"
icon="i-tabler-eye"
label="预览"
@click="previewVideo(row.original.auth_video_url, '授权视频')"
@click="previewVideo(row.auth_video_url, '授权视频')"
/>
</div>
</template>
<template #actions-cell="{ row }">
<template #actions-data="{ row }">
<div class="flex gap-2">
<UButton
color="warning"
color="amber"
variant="soft"
size="xs"
icon="i-tabler-user-cog"
label="录入"
@click="handleProcessTrain(row.original)"
@click="handleProcessTrain(row)"
/>
<UButton
color="error"
color="red"
variant="soft"
size="xs"
icon="i-tabler-trash"
label="删除"
@click="handleDeleteTrain(row.original)"
@click="handleDeleteTrain(row)"
/>
</div>
</template>
@@ -455,7 +452,7 @@ const previewVideo = (videoUrl: string, title: string) => {
<div class="flex justify-end">
<UPagination
v-model:page="pagination.page"
v-model="pagination.page"
:max="9"
:page-count="pagination.pageSize"
:total="trainListResp?.data.total || 0"
@@ -465,28 +462,29 @@ const previewVideo = (videoUrl: string, title: string) => {
</div>
<!-- 录入数字人弹窗 -->
<USlideover v-model:open="isProcessModalOpen">
<template #content>
<USlideover v-model="isProcessModalOpen">
<UCard
:ui="{
body: 'flex-1',
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-1 flex-col"
class="flex flex-col flex-1"
>
<template #header>
<UButton
class="absolute end-5 top-5 z-10 flex"
color="neutral"
variant="ghost"
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="i-tabler-x"
padded
size="sm"
square
variant="ghost"
@click="isProcessModalOpen = false"
/>
<div>
<h3 class="text-lg font-semibold">录入数字人</h3>
<p class="mt-1 text-sm text-gray-500">
<p class="text-sm text-gray-500 mt-1">
"{{ currentTrainItem?.dh_name }}"创建系统数字人并分配给用户
{{ currentTrainItem?.user_id }}
</p>
@@ -499,14 +497,14 @@ const previewVideo = (videoUrl: string, title: string) => {
:state="processFormState"
@submit="onProcessSubmit"
>
<UFormField
<UFormGroup
label="名称"
name="name"
>
<UInput v-model="processFormState.name" />
</UFormField>
</UFormGroup>
<UFormField
<UFormGroup
label="数字人ID"
name="model_id"
description="请输入五位数字人ID"
@@ -516,19 +514,19 @@ const previewVideo = (videoUrl: string, title: string) => {
type="number"
placeholder="请输入数字人ID"
/>
</UFormField>
</UFormGroup>
<UFormField
<UFormGroup
label="描述"
name="description"
>
<UTextarea
v-model="processFormState.description"
:rows="3"
rows="3"
/>
</UFormField>
</UFormGroup>
<UFormField
<UFormGroup
label="供应商类型"
name="type"
>
@@ -537,9 +535,9 @@ const previewVideo = (videoUrl: string, title: string) => {
value-attribute="value"
:options="sourceTypeList"
/>
</UFormField>
</UFormGroup>
<UFormField
<UFormGroup
label="数字人预览图"
required
>
@@ -555,20 +553,18 @@ const previewVideo = (videoUrl: string, title: string) => {
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
avatarFile ? avatarFile.name : '点击或拖拽上传图片'
}}
{{ avatarFile ? avatarFile.name : '点击或拖拽上传图片' }}
</span>
</div>
</div>
</template>
</UniFileDnD>
</UFormField>
</UFormGroup>
<div class="flex justify-end gap-2 pt-4">
<UButton
type="button"
color="neutral"
color="gray"
variant="soft"
@click="isProcessModalOpen = false"
>
@@ -585,7 +581,6 @@ const previewVideo = (videoUrl: string, title: string) => {
</div>
</UForm>
</UCard>
</template>
</USlideover>
</div>
</template>

View File

@@ -54,7 +54,7 @@ const navigateToPage = (path: string) => {
</div>
<div class="p-4">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="page in adminPages"
:key="page.path"
@@ -62,11 +62,14 @@ const navigateToPage = (path: string) => {
@click="navigateToPage(page.path)"
>
<UCard
class="transition-all duration-200 hover:shadow-lg group-hover:scale-105"
class="hover:shadow-lg transition-all duration-200 group-hover:scale-105"
:ui="{
ring: 'ring-1 ring-gray-200 dark:ring-gray-700 group-hover:ring-gray-300 dark:group-hover:ring-gray-600',
}"
>
<div class="flex flex-col items-center p-6 text-center">
<div class="flex flex-col items-center text-center p-6">
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full transition-colors"
class="w-16 h-16 rounded-full flex items-center justify-center mb-4 transition-colors"
:class="{
'bg-blue-100 dark:bg-blue-900/30': page.color === 'blue',
'bg-amber-100 dark:bg-amber-900/30': page.color === 'amber',
@@ -75,7 +78,7 @@ const navigateToPage = (path: string) => {
>
<UIcon
:name="page.icon"
class="h-8 w-8"
class="w-8 h-8"
:class="{
'text-blue-600 dark:text-blue-400': page.color === 'blue',
'text-amber-600 dark:text-amber-400':
@@ -87,24 +90,24 @@ const navigateToPage = (path: string) => {
</div>
<h3
class="mb-2 text-lg font-semibold text-gray-900 dark:text-white"
class="text-lg font-semibold text-gray-900 dark:text-white mb-2"
>
{{ page.title }}
</h3>
<p
class="text-sm leading-relaxed text-gray-600 dark:text-gray-400"
class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed"
>
{{ page.description }}
</p>
<div
class="mt-4 flex items-center text-sm text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300"
class="mt-4 flex items-center text-sm text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300 transition-colors"
>
<span>进入管理</span>
<UIcon
name="i-heroicons-arrow-right"
class="ml-1 h-4 w-4"
class="ml-1 w-4 h-4"
/>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,4 @@
<script lang="ts" setup>
import type { TableColumn } from '#ui/types'
const route = useRoute()
const toast = useToast()
const dayjs = useDayjs()
@@ -37,41 +35,49 @@ const systemServices: ServiceType[] = [
},
]
const columns: TableColumn<UserSchema>[] = [
const columns = [
{
accessorKey: 'id',
header: 'ID',
key: 'id',
label: 'ID',
disabled: true,
},
{
accessorKey: 'avatar',
header: '头像',
key: 'avatar',
label: '头像',
disabled: true,
},
{
accessorKey: 'username',
header: '账户名/姓名',
key: 'username',
label: '账户名/姓名',
disabled: true,
},
{
accessorKey: 'company',
header: '单位',
key: 'company',
label: '单位',
},
{
accessorKey: 'mobile',
header: '手机号',
key: 'mobile',
label: '手机号',
},
{
accessorKey: 'sex',
header: '性别',
key: 'sex',
label: '性别',
},
{
accessorKey: 'auth_code',
header: '角色',
key: 'auth_code',
label: '角色',
},
{
accessorKey: 'actions',
header: '操作',
key: 'actions',
label: '操作',
},
]
const selectedColumns = ref([
...columns.filter((row) => {
return !['auth_code'].includes(row.key)
}),
])
const page = ref(1)
const pageCount = ref(15)
const state_filter = ref<'verified' | 'unverified'>('verified')
@@ -121,7 +127,7 @@ const dhPageCount = ref(10)
watch(dhPageCount, () => (dhPage.value = 1))
onMounted(() => {
if (route.query?.unverified) {
if (!!route.query?.unverified) {
state_filter.value = 'unverified'
}
})
@@ -182,7 +188,7 @@ const items = (row: UserSchema) => [
{
label: '服务和用量',
icon: 'tabler:server-cog',
onClick: () => openSlide(row),
click: () => openSlide(row),
disabled: row.auth_code === 0,
},
],
@@ -191,7 +197,7 @@ const items = (row: UserSchema) => [
disabled: row.id === loginState.user.id,
label: row.auth_code !== 0 ? '停用账号' : '启用账号',
icon: row.auth_code !== 0 ? 'tabler:cancel' : 'tabler:shield-check',
onClick: () => setUserStatus(row.id, row.auth_code === 0),
click: () => setUserStatus(row.id, row.auth_code === 0),
},
],
]
@@ -229,7 +235,7 @@ const onDigitalHumansSelected = (digitalHumans: DigitalHumanItem[]) => {
description: `成功授权 ${res.data.success} 个数字人${
res.data.failed ? `,失败 ${res.data.failed}` : ''
}`,
color: 'success',
color: 'green',
icon: 'tabler:check',
})
refreshDigitalHumansData()
@@ -237,7 +243,7 @@ const onDigitalHumansSelected = (digitalHumans: DigitalHumanItem[]) => {
toast.add({
title: '授权失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
@@ -263,7 +269,7 @@ const revokeDigitalHuman = (uid: number, digitalHumanId: number) => {
toast.add({
title: '撤销成功',
description: '已撤销数字人授权',
color: 'success',
color: 'green',
icon: 'tabler:check',
})
refreshDigitalHumansData()
@@ -271,7 +277,7 @@ const revokeDigitalHuman = (uid: number, digitalHumanId: number) => {
toast.add({
title: '撤销失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
@@ -297,7 +303,7 @@ const setUserStatus = (uid: number, is_verified: boolean) => {
toast.add({
title: '操作成功',
description: `${is_verified ? '启用' : '停用'}账号`,
color: 'success',
color: 'green',
icon: is_verified ? 'tabler:shield-check' : 'tabler:cancel',
})
refreshUsersData()
@@ -305,7 +311,7 @@ const setUserStatus = (uid: number, is_verified: boolean) => {
toast.add({
title: '操作失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
@@ -360,14 +366,14 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
toast.add({
title: '操作成功',
description: `${isActivate ? '开通' : '更新'}服务`,
color: 'success',
color: 'green',
icon: 'tabler:check',
})
} else {
toast.add({
title: '操作失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'tabler:alert-triangle',
})
}
@@ -376,7 +382,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
toast.add({
title: '操作失败',
description: err.message || '未知错误',
color: 'error',
color: 'red',
icon: 'tabler:alert-triangle',
})
})
@@ -397,10 +403,10 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
>
<template #action>
<UButton
color="warning"
color="amber"
icon="tabler:reload"
variant="soft"
@click="() => refreshUsersData()"
@click="refreshUsersData"
>
刷新
</UButton>
@@ -411,21 +417,21 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
line-gradient-to="amber"
/>
<div>
<div class="flex w-full items-center justify-between py-3">
<div class="flex justify-between items-center w-full py-3">
<div class="flex items-center gap-1.5">
<span class="leading-0 text-sm">每页显示:</span>
<span class="text-sm leading-0">每页显示:</span>
<USelect
v-model="pageCount"
:items="[5, 10, 15, 20]"
:options="[5, 10, 15, 20]"
class="me-2 w-20"
size="xs"
/>
</div>
<div class="flex items-center gap-1.5">
<div class="flex gap-1.5 items-center">
<USelectMenu
v-model="state_filter"
:items="[
:options="[
{
label: '正常账号',
value: 'verified',
@@ -437,61 +443,75 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
icon: 'tabler:user-cancel',
},
]"
value-key="value"
: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="columns"
:columns="selectedColumns"
:loading="usersDataStatus === 'pending'"
:progress="{ color: 'amber', animation: 'carousel' }"
:data="usersData?.data.items"
class="rounded-md border dark:border-neutral-800"
:rows="usersData?.data.items"
class="border dark:border-neutral-800 rounded-md"
>
<template #username-cell="{ row }">
<template #username-data="{ row }">
<span
:class="{
'font-semibold text-amber-500':
row.original.id === loginState.user.id,
'font-semibold text-amber-500': row.id === loginState.user.id,
}"
>
{{ row.original.username }}
{{ row.username }}
<span class="text-xs">
{{ row.original.id === loginState.user.id ? ' (本账号)' : '' }}
{{ row.id === loginState.user.id ? ' (本账号)' : '' }}
</span>
</span>
</template>
<template #avatar-cell="{ row }">
<template #avatar-data="{ row }">
<UAvatar
:alt="row.original.username.toUpperCase()"
:src="row.original.avatar"
:alt="row.username.toUpperCase()"
:src="row.avatar"
size="sm"
/>
</template>
<template #sex-cell="{ row }">
{{
row.original.sex === 0 ? '' : row.original.sex === 1 ? '男' : '女'
}}
<template #sex-data="{ row }">
{{ row.sex === 0 ? '' : row.sex === 1 ? '男' : '女' }}
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="items(row.original)">
<template #actions-data="{ row }">
<UDropdown :items="items(row)">
<UButton
color="neutral"
color="gray"
icon="tabler:dots"
variant="ghost"
/>
</UDropdownMenu>
</UDropdown>
</template>
</UTable>
<div class="flex justify-end py-3.5">
<UPagination
v-if="(usersData?.data.total || -1) > 0"
v-model:page="page"
v-model="page"
:page-count="pageCount"
:total="usersData?.data.total || 0"
/>
@@ -499,52 +519,53 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</div>
</div>
<USlideover
v-model:open="isSlideOpen"
:ui="{ content: 'w-screen max-w-3xl' }"
v-model="isSlideOpen"
:ui="{ width: 'w-screen max-w-3xl' }"
>
<template #content>
<UCard
:ui="{
body: 'flex-1 overflow-y-auto',
body: { base: 'flex-1 overflow-y-auto' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col overflow-hidden"
>
<template #header>
<UButton
class="absolute end-5 top-5 z-10 flex"
color="neutral"
variant="ghost"
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-primary text-sm font-medium">
<p class="text-sm font-medium text-primary">
{{ viewingUser?.username }} (UID:{{ viewingUser?.id }})
</p>
</template>
<div class="">
<USeparator
<UDivider
label="服务用量管理"
class="mb-4"
:ui="{ label: 'text-primary-500 dark:text-primary-400' }"
/>
<div class="rounded-md border dark:border-neutral-700">
<div class="border dark:border-neutral-700 rounded-md">
<UTable
:columns="[
{ accessorKey: 'service', header: '服务' },
{ accessorKey: 'status', header: '状态' },
{ accessorKey: 'create_time', header: '开通时间' },
{ accessorKey: 'expire_time', header: '过期时间' },
{ accessorKey: 'remain_count', header: '余量(秒)' },
{ accessorKey: 'actions' },
{ key: 'service', label: '服务' },
{ key: 'status', label: '状态' },
{ key: 'create_time', label: '开通时间' },
{ key: 'expire_time', label: '过期时间' },
{ key: 'remain_count', label: '余量(秒)' },
{ key: 'actions' },
]"
:loading="userBalancesStatus === 'pending'"
:data="[
:rows="[
...systemServices,
// 如果 userBalances?.data.items 具有 systemServices 中没有的服务,则添加到列表中
...(userBalances?.data.items
@@ -558,28 +579,26 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
})) || []),
]"
>
<template #service-cell="{ row }">
<template #service-data="{ row }: { row: ServiceType }">
{{
systemServices.find((s) => s.tag === row.original.tag)
?.name || row.original.tag
systemServices.find((s) => s.tag === row.tag)?.name || row.tag
}}
</template>
<template #status-cell="{ row }">
<template #status-data="{ row }">
<UBadge
v-if="!getBalanceByTag(row.original.tag)"
color="neutral"
variant="soft"
v-if="!getBalanceByTag(row.tag)"
color="gray"
variant="solid"
size="xs"
>
未开通
</UBadge>
<UBadge
v-else-if="
getBalanceByTag(row.original.tag)!.expire_time <
dayjs().unix()
getBalanceByTag(row.tag)!.expire_time < dayjs().unix()
"
color="error"
color="red"
variant="subtle"
size="xs"
>
@@ -587,7 +606,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</UBadge>
<UBadge
v-else
color="success"
color="green"
variant="subtle"
size="xs"
>
@@ -595,42 +614,40 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</UBadge>
</template>
<template #create_time-cell="{ row }">
<template #create_time-data="{ row }">
<span class="text-xs">
{{
!!getBalanceByTag(row.original.tag)
!!getBalanceByTag(row.tag)
? dayjs(
getBalanceByTag(row.original.tag)!.create_time *
1000
getBalanceByTag(row.tag)!.create_time * 1000
).format('YYYY-MM-DD HH:mm:ss')
: '未开通'
}}
</span>
</template>
<template #expire_time-cell="{ row }">
<template #expire_time-data="{ row }">
<span class="text-xs">
{{
!!getBalanceByTag(row.original.tag)
!!getBalanceByTag(row.tag)
? dayjs(
getBalanceByTag(row.original.tag)!.expire_time *
1000
getBalanceByTag(row.tag)!.expire_time * 1000
).format('YYYY-MM-DD HH:mm:ss')
: '未开通'
}}
</span>
</template>
<template #remain_count-cell="{ row }">
<template #remain_count-data="{ row }">
<span class="text-sm">
{{ getBalanceByTag(row.original.tag)?.remain_count || 0 }}
{{ getBalanceByTag(row.tag)?.remain_count || 0 }}
</span>
</template>
<template #actions-cell="{ row }">
<template #actions-data="{ row }">
<UButton
v-if="!getBalanceByTag(row.original.tag)"
color="success"
v-if="!getBalanceByTag(row.tag)"
color="green"
icon="tabler:clock-check"
size="xs"
variant="soft"
@@ -638,7 +655,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
() => {
isActivateBalance = true
userBalanceEditing = true
userBalanceState.request_type = row.original.tag
userBalanceState.request_type = row.tag
}
"
>
@@ -646,10 +663,9 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</UButton>
<UButton
v-else-if="
getBalanceByTag(row.original.tag)!.expire_time <
dayjs().unix()
getBalanceByTag(row.tag)!.expire_time < dayjs().unix()
"
color="info"
color="teal"
icon="tabler:clock-plus"
size="xs"
variant="soft"
@@ -657,7 +673,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
() => {
isActivateBalance = false
userBalanceEditing = true
userBalanceState.request_type = row.original.tag
userBalanceState.request_type = row.tag
}
"
>
@@ -665,7 +681,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</UButton>
<UButton
v-else
color="info"
color="sky"
icon="tabler:rotate-clockwise"
size="xs"
variant="soft"
@@ -673,11 +689,11 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
() => {
isActivateBalance = false
userBalanceEditing = true
userBalanceState.request_type = row.original.tag
userBalanceState.request_type = row.tag
userBalanceState.expire_time =
getBalanceByTag(row.original.tag)!.expire_time * 1000
getBalanceByTag(row.tag)!.expire_time * 1000
userBalanceState.remain_count = getBalanceByTag(
row.original.tag
row.tag
)!.remain_count
}
"
@@ -688,13 +704,25 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</UTable>
<UModal
v-model:open="isBalanceEditModalOpen"
:ui="{ content: 'w-xl' }"
v-model="isBalanceEditModalOpen"
:ui="{ width: 'w-xl' }"
>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-1">
<!-- 为 {{ viewingUser!.username }}
<span class="text-primary">{{ isActivateBalance ? '开通' : '续期' }}</span>
服务:
{{
systemServices.find(
(s) => s.tag === userBalanceState.request_type
)?.name || userBalanceState.request_type
}} -->
<div class="flex justify-between items-center gap-1">
<h1 class="text-sm font-medium">
{{ isActivateBalance ? '开通' : '续期' }}
{{
@@ -705,7 +733,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
服务
</h1>
<p
class="inline-flex items-center gap-1 text-xs text-indigo-500"
class="text-xs text-indigo-500 inline-flex items-center gap-1"
>
<UIcon
name="tabler:user-circle"
@@ -717,7 +745,7 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</template>
<div class="flex justify-between gap-4">
<UFormField
<UFormGroup
label="到期时间"
class="flex-1"
>
@@ -745,21 +773,21 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
/>
</template>
</UPopover>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="服务时长(秒)"
class="flex-1"
>
<UInput v-model="userBalanceState.remain_count" />
</UFormField>
</UFormGroup>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<UButton
color="neutral"
variant="ghost"
color="gray"
size="sm"
variant="ghost"
@click="isBalanceEditModalOpen = false"
>
取消
@@ -780,37 +808,36 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</div>
</template>
</UCard>
</template>
</UModal>
</div>
<USeparator
<UDivider
label="数字人权限管理"
class="my-4"
:ui="{ label: 'text-primary-500 dark:text-primary-400' }"
/>
<div class="rounded-md border dark:border-neutral-700">
<div class="border dark:border-neutral-700 rounded-md">
<UTable
:columns="[
{ accessorKey: 'name', header: '名称' },
{ accessorKey: 'digital_human_id', header: '本地ID' },
{ accessorKey: 'model_id', header: '上游ID' },
{ accessorKey: 'actions' },
{ key: 'name', label: '名称' },
{ key: 'digital_human_id', label: '本地ID' },
{ key: 'model_id', label: '上游ID' },
{ key: 'actions' },
]"
:loading="digitalHumansDataStatus === 'pending'"
:data="digitalHumansData?.data.items"
:rows="digitalHumansData?.data.items"
>
<template #actions-cell="{ row }">
<template #actions-data="{ row }">
<UButton
color="neutral"
variant="ghost"
color="gray"
icon="tabler:cancel"
size="xs"
variant="ghost"
@click="
revokeDigitalHuman(
viewingUser?.id || 0,
row.original.digital_human_id || 0
row.digital_human_id
)
"
>
@@ -829,14 +856,13 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
</UButton>
<UPagination
v-if="(digitalHumansData?.data.total || -1) > 0"
v-model:page="dhPage"
v-model="dhPage"
:page-count="dhPageCount"
:total="digitalHumansData?.data.total || 0"
/>
</div>
</div>
</UCard>
<ModalDigitalHumanSelect
:disabled-digital-human-ids="
digitalHumansData?.data.items.map((d) => d.model_id)
@@ -850,7 +876,8 @@ const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
onDigitalHumansSelected(digitalHumans as DigitalHumanItem[])
"
/>
</template>
</USlideover>
</LoginNeededContent>
</template>
<style scoped></style>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { number, object, string, type InferType } from 'yup'
import type { FormSubmitEvent, TableColumn } from '#ui/types'
import type { FormSubmitEvent } from '#ui/types'
const toast = useToast()
const loginState = useLoginState()
@@ -94,7 +94,7 @@ const onSystemAvatarDelete = (row: DigitalHumanItem) => {
toast.add({
title: '删除成功',
description: '数字人已删除',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
refreshSystemAvatarList()
@@ -102,7 +102,7 @@ const onSystemAvatarDelete = (row: DigitalHumanItem) => {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -111,43 +111,43 @@ const onSystemAvatarDelete = (row: DigitalHumanItem) => {
toast.add({
title: '删除失败',
description: '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
}
const columns: TableColumn<DigitalHumanItem>[] = [
const columns = [
{
accessorKey: 'avatar',
header: '图片',
key: 'avatar',
label: '图片',
},
{
accessorKey: 'name',
header: '名称',
key: 'name',
label: '名称',
},
{
accessorKey: 'model_id',
header: 'ID',
key: 'model_id',
label: 'ID',
},
{
accessorKey: 'type',
header: '来源',
key: 'type',
label: '来源',
},
{
accessorKey: 'description',
header: '备注',
key: 'description',
label: '备注',
},
{
accessorKey: 'actions',
key: 'actions',
},
]
const sourceTypeList = [
{ label: 'xsh_wm', value: 1, color: 'info' as const }, // 万木(腾讯)
{ label: 'xsh_zy', value: 2, color: 'success' as const }, // XSH 自有
{ label: 'xsh_fh', value: 3, color: 'warning' as const }, // 硅基(泛化数字人)
{ label: 'xsh_bb', value: 4, color: 'primary' as const }, // 百度小冰
{ label: 'xsh_wm', value: 1, color: 'blue' }, // 万木(腾讯)
{ label: 'xsh_zy', value: 2, color: 'green' }, // XSH 自有
{ label: 'xsh_fh', value: 3, color: 'purple' }, // 硅基(泛化数字人)
{ label: 'xsh_bb', value: 4, color: 'indigo' }, // 百度小冰
]
const isCreateSlideOpen = ref(false)
@@ -189,7 +189,7 @@ const onCreateAvatarSubmit = (event: FormSubmitEvent<CreateAvatarSchema>) => {
toast.add({
title: '创建成功',
description: '数字人已创建',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
refreshSystemAvatarList()
@@ -199,7 +199,7 @@ const onCreateAvatarSubmit = (event: FormSubmitEvent<CreateAvatarSchema>) => {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -208,7 +208,7 @@ const onCreateAvatarSubmit = (event: FormSubmitEvent<CreateAvatarSchema>) => {
toast.add({
title: '创建失败',
description: '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -216,22 +216,20 @@ const onCreateAvatarSubmit = (event: FormSubmitEvent<CreateAvatarSchema>) => {
const onAvatarUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
try {
const url = await useFileGo(file, 'material')
createAvatarState.avatar = url
toast.add({
title: '上传文件成功',
description: '文件已上传',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} catch {
toast.add({
title: '上传文件失败',
description: '请重试',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -247,7 +245,7 @@ const onAvatarUpload = async (files: FileList) => {
>
<template #action>
<UButton
color="neutral"
color="gray"
variant="soft"
:label="showSystemAvatar ? '显示用户数字人' : '显示系统数字人'"
@click="showSystemAvatar = !showSystemAvatar"
@@ -259,20 +257,20 @@ const onAvatarUpload = async (files: FileList) => {
: 'tabler:layout-grid'
"
variant="soft"
color="neutral"
color="gray"
@click="data_layout = data_layout === 'grid' ? 'list' : 'grid'"
:label="data_layout === 'grid' ? '列表视图' : '宫格视图'"
/>
<UButton
v-if="loginState.user.auth_code === 2"
color="warning"
color="amber"
variant="soft"
icon="tabler:user-cog"
label="定制管理"
:to="'/generation/admin/digital-human-train'"
/>
<UButton
color="info"
color="blue"
variant="soft"
icon="tabler:user-plus"
label="定制数字人"
@@ -280,7 +278,7 @@ const onAvatarUpload = async (files: FileList) => {
/>
<UButton
v-if="loginState.user.auth_code === 2"
color="warning"
color="amber"
variant="soft"
icon="tabler:plus"
label="创建数字人"
@@ -307,18 +305,18 @@ const onAvatarUpload = async (files: FileList) => {
? systemAvatarList?.data.items
: userAvatarList?.data.items"
:key="avatar.model_id || k"
class="shadow-xs aspect-9/16 group relative w-full overflow-hidden rounded-lg"
class="relative rounded-lg shadow overflow-hidden w-full aspect-[9/16] group"
>
<NuxtImg
:src="avatar.avatar"
class="h-full w-full object-cover"
class="w-full h-full object-cover"
/>
<div
class="bg-linear-to-t absolute inset-x-0 bottom-0 flex gap-2 from-black/50 to-transparent p-2"
class="absolute inset-x-0 bottom-0 p-2 bg-gradient-to-t from-black/50 to-transparent flex gap-2"
>
<UBadge
color="neutral"
variant="subtle"
color="white"
variant="solid"
icon="tabler:user-screen"
>
{{ avatar.name }}
@@ -336,11 +334,11 @@ const onAvatarUpload = async (files: FileList) => {
</template>
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center bg-white/50 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 dark:bg-neutral-800/50"
class="absolute inset-0 flex flex-col justify-center items-center bg-white/50 dark:bg-neutral-800/50 backdrop-blur opacity-0 group-hover:opacity-100 transition-opacity"
>
<UFieldGroup>
<UButtonGroup>
<UButton
color="neutral"
color="black"
icon="tabler:download"
label="下载图片"
@click="
@@ -353,9 +351,9 @@ const onAvatarUpload = async (files: FileList) => {
}
"
/>
</UFieldGroup>
</UButtonGroup>
<span
class="pt-4 text-xs font-medium text-neutral-400 dark:text-neutral-300"
class="text-xs font-medium text-neutral-400 dark:text-neutral-300 pt-4"
>
ID: {{ avatar.model_id }}
</span>
@@ -365,40 +363,39 @@ const onAvatarUpload = async (files: FileList) => {
<div v-else>
<div class="flex flex-col gap-4">
<UTable
:data="
:rows="
showSystemAvatar
? systemAvatarList?.data.items
: userAvatarList?.data.items
"
:columns="columns"
:loading="userAvatarStatus === 'pending'"
loading-color="warning"
loading-animation="carousel"
class="rounded-md border dark:border-neutral-800"
:progress="{ color: 'amber', animation: 'carousel' }"
class="border dark:border-neutral-800 rounded-md"
>
<template #avatar-cell="{ row }">
<template #avatar-data="{ row }">
<NuxtImg
:src="row.original.avatar"
class="h-16 w-auto rounded-lg object-contain"
:src="row.avatar"
class="h-16 aspect-[9/16] rounded-lg"
/>
</template>
<template #type-cell="{ row }">
<template #type-data="{ row }">
<template
v-for="(t, i) in sourceTypeList"
:key="i"
>
<UBadge
v-if="t.value === row.original.type"
v-if="t.value === row.type"
variant="subtle"
:color="t.color"
:label="t.label"
/>
</template>
</template>
<template #actions-cell="{ row }">
<template #actions-data="{ row }">
<div class="flex gap-2">
<UButton
color="neutral"
color="gray"
icon="tabler:download"
label="下载图片"
variant="soft"
@@ -406,29 +403,29 @@ const onAvatarUpload = async (files: FileList) => {
@click="
() => {
const { download } = useDownload(
row.original.avatar,
`数字人_${row.original.name}.png`
row.avatar,
`数字人_${row.name}.png`
)
download()
}
"
/>
<UButton
color="error"
color="red"
icon="tabler:trash"
label="删除"
variant="soft"
size="xs"
@click="onSystemAvatarDelete(row.original)"
@click="onSystemAvatarDelete(row)"
/>
</div>
</template>
</UTable>
</div>
</div>
<div class="mt-4 flex justify-end">
<div class="flex justify-end mt-4">
<UPagination
v-model:page="pagination.page"
v-model="pagination.page"
:max="9"
:page-count="pagination.pageSize"
:total="userAvatarList?.data.total || 0"
@@ -436,18 +433,19 @@ const onAvatarUpload = async (files: FileList) => {
</div>
</div>
<USlideover v-model:open="isCreateSlideOpen">
<template #content>
<USlideover v-model="isCreateSlideOpen">
<UCard
:ui="{
body: 'flex-1',
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-1 flex-col"
class="flex flex-col flex-1"
>
<template #header>
<UButton
class="absolute end-5 top-5 z-10 flex"
color="neutral"
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="tabler:x"
padded
size="sm"
@@ -464,25 +462,25 @@ const onAvatarUpload = async (files: FileList) => {
:state="createAvatarState"
@submit="onCreateAvatarSubmit"
>
<UFormField
<UFormGroup
label="名称"
name="name"
>
<UInput v-model="createAvatarState.name" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="备注"
name="description"
>
<UInput v-model="createAvatarState.description" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="ID"
name="model_id"
>
<UInput v-model="createAvatarState.model_id" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="图片"
name="avatar"
>
@@ -490,27 +488,26 @@ const onAvatarUpload = async (files: FileList) => {
accept="image/png,image/jpeg"
@change="onAvatarUpload"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="类型"
name="type"
>
<USelectMenu
v-model="createAvatarState.type"
:items="sourceTypeList"
value-key="value"
value-attribute="value"
:options="sourceTypeList"
/>
</UFormField>
<UFormField class="flex justify-end pt-4">
</UFormGroup>
<UFormGroup class="flex justify-end pt-4">
<UButton
type="submit"
color="primary"
label="创建"
/>
</UFormField>
</UFormGroup>
</UForm>
</UCard>
</template>
</USlideover>
<!-- 数字人定制对话框 -->

View File

@@ -4,9 +4,8 @@ import SlideCreateCourse from '~/components/SlideCreateCourse.vue'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const toast = useToast()
const overlay = useOverlay()
const modal = overlay.create(ModalAuthentication)
const slide = overlay.create(SlideCreateCourse)
const modal = useModal()
const slide = useSlideover()
const loginState = useLoginState()
const deletePending = ref(false)
@@ -29,10 +28,12 @@ const { data: courseList, refresh: refreshCourseList } = useAsyncData(
}
)
const onCreateCourseClick = async () => {
const slideInst = slide.open()
await slideInst
const onCreateCourseClick = () => {
slide.open(SlideCreateCourse, {
onSuccess: () => {
refreshCourseList()
},
})
}
const onCourseDelete = (task_id: string) => {
@@ -52,14 +53,14 @@ const onCourseDelete = (task_id: string) => {
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -106,7 +107,7 @@ onMounted(() => {
@click="
() => {
if (!loginState.is_logged_in) {
modal.open()
modal.open(ModalAuthentication)
return
}
onCreateCourseClick()
@@ -120,7 +121,7 @@ onMounted(() => {
<Transition name="loading-screen">
<div
v-if="courseList?.data.items.length === 0"
class="flex w-full flex-col items-center justify-center gap-2 py-20"
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"
@@ -133,7 +134,7 @@ onMounted(() => {
class="p-4"
>
<div
class="relative grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 fhd:grid-cols-5"
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"
@@ -148,9 +149,9 @@ onMounted(() => {
/>
</TransitionGroup>
</div>
<div class="mt-4 flex justify-end">
<div class="flex justify-end mt-4">
<UPagination
v-model:page="page"
v-model="page"
:max="9"
:page-count="16"
:total="courseList?.data.total || 0"

View File

@@ -5,8 +5,7 @@ import { useTourState } from '~/composables/useTourState'
import SlideCreateCourseGreen from '~/components/SlideCreateCourseGreen.vue'
const route = useRoute()
const overlay = useOverlay()
const slide = overlay.create(SlideCreateCourseGreen)
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
const tourState = useTourState()
@@ -36,10 +35,12 @@ const { data: videoList, refresh: refreshVideoList } = useAsyncData(
}
)
const onCreateCourseGreenClick = async () => {
const slideInst = slide.open()
await slideInst
const onCreateCourseGreenClick = () => {
slide.open(SlideCreateCourseGreen, {
onSuccess: () => {
refreshVideoList()
},
})
}
const onCourseGreenDelete = (task: GBVideoItem) => {
@@ -57,14 +58,14 @@ const onCourseGreenDelete = (task: GBVideoItem) => {
toast.add({
title: '删除成功',
description: '已删除任务记录',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -131,11 +132,12 @@ onMounted(() => {
"
>
<template #action>
<UFieldGroup size="md">
<UButtonGroup size="md">
<UInput
id="input-search"
v-model="searchInput"
:autofocus="false"
:ui="{ icon: { trailing: { pointer: '' } } }"
autocomplete="off"
placeholder="标题搜索"
variant="outline"
@@ -144,14 +146,14 @@ onMounted(() => {
<UButton
v-show="searchInput !== ''"
:padded="false"
color="neutral"
color="gray"
icon="i-tabler-x"
variant="link"
@click="searchInput = ''"
/>
</template>
</UInput>
</UFieldGroup>
</UButtonGroup>
<UButton
id="button-create"
:trailing="false"
@@ -170,7 +172,7 @@ onMounted(() => {
<Transition name="loading-screen">
<div
v-if="videoList?.data.items.length === 0"
class="flex w-full flex-col items-center justify-center gap-2 py-20"
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"
@@ -181,7 +183,7 @@ onMounted(() => {
<div v-else>
<div class="p-4">
<div
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3 fhd:grid-cols-5"
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"
@@ -196,9 +198,9 @@ onMounted(() => {
/>
</TransitionGroup>
</div>
<div class="mt-4 flex justify-end">
<div class="flex justify-end mt-4">
<UPagination
v-model:page="page"
v-model="page"
:max="9"
:page-count="pageCount"
:total="videoList?.data.total || 0"

View File

@@ -110,14 +110,14 @@ const onSystemTitlesDelete = (titles: TitlesTemplate) => {
toast.add({
title: '删除成功',
description: '已删除系统片头模板',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -126,7 +126,7 @@ const onSystemTitlesDelete = (titles: TitlesTemplate) => {
toast.add({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -153,14 +153,14 @@ const onUserTitlesDelete = (titles: TitlesTemplate) => {
toast.add({
title: '删除成功',
description: '已删除片头素材',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -188,14 +188,14 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
toast.add({
title: '提交成功',
description: '已提交片头制作请求',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '提交失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -217,7 +217,7 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
>
<template #action>
<UButton
color="warning"
color="amber"
icon="tabler:plus"
variant="soft"
v-if="loginState.user.auth_code === 2"
@@ -231,18 +231,18 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
</div>
<div class="p-4">
<div
class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"
class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-4"
v-if="systemTitlesTemplateStatus === 'pending'"
>
<USkeleton
class="aspect-video w-full"
class="w-full aspect-video"
v-for="i in systemPagination.pageSize"
:key="i"
/>
</div>
<div
v-else-if="systemTitlesTemplate?.data.items.length === 0"
class="flex w-full flex-col items-center justify-center gap-2 py-20"
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"
@@ -253,7 +253,7 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
</p>
</div>
<div
class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"
class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-4"
v-else
>
<AigcGenerationTitlesTemplate
@@ -298,18 +298,18 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
</template>
</UAlert>
<div
class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"
class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-4"
v-if="userTitlesTemplateStatus === 'pending'"
>
<USkeleton
class="aspect-video w-full"
class="w-full aspect-video"
v-for="i in userPagination.pageSize"
:key="i"
/>
</div>
<div
v-else-if="userTitlesTemplate?.data.items.length === 0"
class="flex w-full flex-col items-center justify-center gap-2 py-20"
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"
@@ -320,7 +320,7 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
</p>
</div>
<div
class="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"
class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5 gap-4"
v-else
>
<AigcGenerationTitlesTemplate
@@ -333,19 +333,23 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
</div>
</div>
<UModal v-model:open="isUserTitlesRequestModalActive">
<template #content>
<UCard>
<UModal v-model="isUserTitlesRequestModalActive">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<div
class="overflow-hidden text-base font-semibold leading-6 text-gray-900 dark:text-white"
class="text-base font-semibold leading-6 text-gray-900 dark:text-white overflow-hidden"
>
<p>使用模板</p>
</div>
<UButton
class="-my-1"
color="neutral"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isUserTitlesRequestModalActive = false"
@@ -360,33 +364,33 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
:state="userTitlesState"
@submit="onUserTitlesSubmit"
>
<UFormField
<UFormGroup
label="课程名称"
name="title"
required
>
<UInput v-model="userTitlesState.title" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="主讲人"
name="description"
required
>
<UInput v-model="userTitlesState.description" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="备注"
name="remark"
help="可选,可以在此处填写学校、单位等额外信息"
>
<UTextarea v-model="userTitlesState.remark" />
</UFormField>
<UFormField name="title_id">
</UFormGroup>
<UFormGroup name="title_id">
<UInput
type="hidden"
v-model="userTitlesState.title_id"
/>
</UFormField>
</UFormGroup>
<UAlert
icon="tabler:info-circle"
@@ -413,7 +417,6 @@ const onUserTitlesSubmit = (event: FormSubmitEvent<UserTitlesSchema>) => {
</UForm>
</div>
</UCard>
</template>
</UModal>
</div>
</template>

View File

@@ -102,7 +102,7 @@ const onCreateSubmit = (event: FormSubmitEvent<PPTCreateSchema>) => {
toast.add({
title: '创建成功',
description: '已加入模板库',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
isCreateSlideOpen.value = false
@@ -120,14 +120,14 @@ const onCreateSubmit = (event: FormSubmitEvent<PPTCreateSchema>) => {
toast.add({
title: '创建失败',
description: '请检查输入是否正确',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
}
const onFileSelect = async (files: FileList, type: 'preview' | 'ppt') => {
const url = await useFileGo(files[0]!, 'material')
const url = await useFileGo(files[0], 'material')
if (type === 'preview') {
pptCreateState.preview_url = url
} else {
@@ -136,7 +136,7 @@ const onFileSelect = async (files: FileList, type: 'preview' | 'ppt') => {
toast.add({
title: '上传成功',
description: `已上传 ${type === 'preview' ? '预览图' : 'PPT 文件'}`,
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
}
@@ -156,14 +156,14 @@ const onDeletePPT = (ppt: PPTTemplate) => {
toast.add({
title: '删除成功',
description: '已删除模板',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -172,7 +172,7 @@ const onDeletePPT = (ppt: PPTTemplate) => {
toast.add({
title: '删除失败',
description: '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -196,7 +196,7 @@ const onCreateCat = () => {
toast.add({
title: '创建成功',
description: '已加入分类列表',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
createCatInput.value = ''
@@ -206,7 +206,7 @@ const onCreateCat = () => {
toast.add({
title: '创建失败',
description: '请检查输入是否正确',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -228,14 +228,14 @@ const onDeleteCat = (cat: PPTCategory) => {
toast.add({
title: '删除成功',
description: '已删除分类',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
} else {
toast.add({
title: '删除失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -244,7 +244,7 @@ const onDeleteCat = (cat: PPTCategory) => {
toast.add({
title: '删除失败',
description: '请检查输入是否正确',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
@@ -262,7 +262,7 @@ const onDeleteCat = (cat: PPTCategory) => {
<UButton
v-if="loginState.user.auth_code === 2"
label="分类管理"
color="warning"
color="amber"
variant="soft"
icon="tabler:grid"
@click="isCatSlideOpen = true"
@@ -270,7 +270,7 @@ const onDeleteCat = (cat: PPTCategory) => {
<UButton
v-if="loginState.user.auth_code === 2"
label="创建模板"
color="warning"
color="amber"
variant="soft"
icon="tabler:plus"
@click="isCreateSlideOpen = true"
@@ -293,7 +293,7 @@ const onDeleteCat = (cat: PPTCategory) => {
'bg-primary text-white': selectedCat === cat.id,
'bg-gray-100 text-gray-500': selectedCat !== cat.id,
}"
class="cursor-pointer rounded-lg px-4 py-2 text-sm"
class="rounded-lg px-4 py-2 text-sm cursor-pointer"
>
{{ cat.type }}
</div>
@@ -302,7 +302,7 @@ const onDeleteCat = (cat: PPTCategory) => {
<div class="space-y-4">
<div
v-if="pptTemplates?.data.items.length === 0"
class="flex w-full flex-col items-center justify-center gap-2 py-20"
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"
@@ -314,20 +314,20 @@ const onDeleteCat = (cat: PPTCategory) => {
</div>
<div
v-else
class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4 mt-4"
>
<div
v-for="ppt in pptTemplates?.data.items"
:key="ppt.id"
class="relative overflow-hidden rounded-lg bg-white shadow-md"
class="relative bg-white rounded-lg shadow-md overflow-hidden"
>
<NuxtImg
:src="ppt.preview_url"
:alt="ppt.title"
class="aspect-video w-full object-cover"
class="w-full aspect-video object-cover"
/>
<div
class="bg-linear-to-t absolute inset-x-0 bottom-0 flex items-end justify-between from-black/50 to-transparent p-3 pt-6"
class="absolute inset-x-0 bottom-0 p-3 pt-6 flex justify-between items-end bg-gradient-to-t from-black/50 to-transparent"
>
<div class="space-y-0.5">
<h3 class="text-base font-bold text-white">{{ ppt.title }}</h3>
@@ -340,7 +340,7 @@ const onDeleteCat = (cat: PPTCategory) => {
<UButton
v-if="loginState.user.auth_code === 2"
size="sm"
color="error"
color="red"
icon="tabler:trash"
variant="soft"
@click="onDeletePPT(ppt)"
@@ -357,30 +357,31 @@ const onDeleteCat = (cat: PPTCategory) => {
</div>
</div>
<div class="flex w-full justify-end">
<div class="w-full flex justify-end">
<UPagination
v-if="(pptTemplates?.data.total || 0) > pagination.perpage"
:total="pptTemplates?.data.total"
:page-count="pagination.perpage"
:max="9"
v-model:page="pagination.page"
v-model="pagination.page"
/>
</div>
</div>
</div>
<USlideover v-model:open="isCreateSlideOpen">
<template #content>
<USlideover v-model="isCreateSlideOpen">
<UCard
:ui="{
body: 'flex-1',
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-1 flex-col"
class="flex flex-col flex-1"
>
<template #header>
<UButton
class="absolute end-5 top-5 z-10 flex"
color="neutral"
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="tabler:x"
padded
size="sm"
@@ -397,31 +398,32 @@ const onDeleteCat = (cat: PPTCategory) => {
:state="pptCreateState"
@submit="onCreateSubmit"
>
<UFormField
<UFormGroup
label="模板标题"
name="title"
>
<UInput v-model="pptCreateState.title" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="模板描述"
name="description"
>
<UTextarea v-model="pptCreateState.description" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="模板分类"
name="type"
>
<USelectMenu
v-model="pptCreateState.type"
:items="selectMenuOptions"
value-key="value"
value-attribute="value"
option-attribute="label"
searchable
searchable-placeholder="搜索现有分类..."
:options="selectMenuOptions"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="预览图"
name="preview_url"
>
@@ -429,8 +431,8 @@ const onDeleteCat = (cat: PPTCategory) => {
@change="onFileSelect($event, 'preview')"
accept="image/png,image/jpeg"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="PPT 文件"
name="file_url"
>
@@ -438,7 +440,7 @@ const onDeleteCat = (cat: PPTCategory) => {
@change="onFileSelect($event, 'ppt')"
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
/>
</UFormField>
</UFormGroup>
<div class="flex justify-end">
<UButton
@@ -449,21 +451,20 @@ const onDeleteCat = (cat: PPTCategory) => {
</div>
</UForm>
</UCard>
</template>
</USlideover>
<USlideover v-model:open="isCatSlideOpen">
<template #content>
<USlideover v-model="isCatSlideOpen">
<UCard
:ui="{
body: 'flex-1',
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-1 flex-col"
class="flex flex-col flex-1"
>
<template #header>
<UButton
class="absolute end-5 top-5 z-10 flex"
color="neutral"
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="tabler:x"
padded
size="sm"
@@ -475,8 +476,8 @@ const onDeleteCat = (cat: PPTCategory) => {
</template>
<div class="space-y-4">
<UFormField label="创建分类">
<UFieldGroup
<UFormGroup label="创建分类">
<UButtonGroup
orientation="horizontal"
class="w-full"
size="lg"
@@ -488,42 +489,41 @@ const onDeleteCat = (cat: PPTCategory) => {
/>
<UButton
icon="tabler:plus"
color="neutral"
color="gray"
label="创建"
:disabled="!createCatInput"
@click="onCreateCat"
/>
</UFieldGroup>
</UFormField>
<div class="rounded-md border dark:border-neutral-700">
</UButtonGroup>
</UFormGroup>
<div class="border dark:border-neutral-700 rounded-md">
<UTable
:columns="[
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'type', header: '分类' },
{ accessorKey: 'create_time', header: '创建时间' },
{ accessorKey: 'actions' },
{ key: 'id', label: 'ID' },
{ key: 'type', label: '分类' },
{ key: 'create_time', label: '创建时间' },
{ key: 'actions' },
]"
:rows="pptCategories?.data.items"
>
<template #create_time-data="{ row }">
{{
dayjs(row.original.create_time * 1000).format('YYYY-MM-DD HH:mm:ss')
dayjs(row.create_time * 1000).format('YYYY-MM-DD HH:mm:ss')
}}
</template>
<template #actions-data="{ row }">
<UButton
color="error"
color="red"
icon="tabler:trash"
size="xs"
variant="soft"
@click="onDeleteCat(row.original)"
@click="onDeleteCat(row)"
/>
</template>
</UTable>
</div>
</div>
</UCard>
</template>
</USlideover>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { number, object, string, ref as yref, type InferType } from 'yup'
import type { FormSubmitEvent, TabsItem } from '#ui/types'
import type { FormSubmitEvent } from '#ui/types'
const toast = useToast()
const route = useRoute()
@@ -9,31 +9,30 @@ const loginState = useLoginState()
const tabs = [
{
slot: 'info' as const,
slot: 'info',
label: '基本资料',
icon: 'tabler:user-square-rounded',
},
{
slot: 'security' as const,
slot: 'security',
label: '账号安全',
icon: 'tabler:shield-half-filled',
},
] satisfies TabsItem[]
]
const currentTab = computed({
get() {
// const index = tabs.findIndex((item) => item.slot === route.query.tab)
// if (index === -1) {
// return 0
// }
const index = tabs.findIndex((item) => item.slot === route.query.tab)
if (index === -1) {
return 0
}
// return index
return (route.query.tab as string) || tabs[0]?.slot || 'info'
return index
},
set(tab) {
set(value) {
// Hash is specified here to prevent the page from scrolling to the top
router.replace({
query: { tab },
query: { tab: tabs[value].slot },
})
},
})
@@ -81,7 +80,7 @@ const onEditProfileSubmit = (event: FormSubmitEvent<EditProfileSchema>) => {
toast.add({
title: '个人资料已更新',
description: '您的个人资料已更新成功',
color: 'success',
color: 'green',
})
loginState.updateProfile()
isEditProfileModified.value = false
@@ -89,7 +88,7 @@ const onEditProfileSubmit = (event: FormSubmitEvent<EditProfileSchema>) => {
toast.add({
title: '更新失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
})
}
})
@@ -97,7 +96,7 @@ const onEditProfileSubmit = (event: FormSubmitEvent<EditProfileSchema>) => {
toast.add({
title: '更新失败',
description: err.message || '未知错误',
color: 'error',
color: 'red',
})
})
}
@@ -136,7 +135,7 @@ const onChangePasswordSubmit = (
toast.add({
title: '密码已修改',
description: '请重新登录',
color: 'success',
color: 'green',
})
setTimeout(() => {
loginState.logout()
@@ -146,7 +145,7 @@ const onChangePasswordSubmit = (
toast.add({
title: '修改密码失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
})
}
})
@@ -154,7 +153,7 @@ const onChangePasswordSubmit = (
toast.add({
title: '修改密码失败',
description: err.message || '未知错误',
color: 'error',
color: 'red',
})
})
}
@@ -164,8 +163,8 @@ const onChangePasswordSubmit = (
<LoginNeededContent
content-class="w-full h-full bg-white dark:bg-neutral-900 p-4 sm:p-0"
>
<div class="container mx-auto flex max-w-7xl flex-col gap-12 pt-12">
<h1 class="inline-flex items-center gap-2 text-2xl font-medium">
<div class="container max-w-[1280px] mx-auto pt-12 flex flex-col gap-12">
<h1 class="text-2xl font-medium inline-flex items-center gap-2">
<UIcon
name="line-md:person"
class="text-3xl"
@@ -180,8 +179,10 @@ const onChangePasswordSubmit = (
:src="loginState.user?.avatar"
:alt="loginState.user?.nickname || loginState.user?.username"
:ui="{
root: 'size-14 text-4xl',
image: 'rounded-xl',
rounded: 'rounded-xl',
size: {
huge: 'w-48 h-48 text-4xl',
},
}"
/>
<div>
@@ -202,19 +203,16 @@ const onChangePasswordSubmit = (
<div>
<UTabs
v-model="currentTab"
orientation="vertical"
:items="tabs"
orientation="vertical"
:ui="{
root: 'w-full flex flex-col sm:flex-row items-start gap-4 sm:gap-16',
list: 'w-full sm:w-48 h-fit bg-transparent',
indicator:
'data-[state=active]:bg-neutral-100 data-[state=active]:dark:bg-neutral-700',
trigger: 'w-full',
// list: {
// width: 'w-full sm:w-48 h-fit',
// background: 'bg-transparent',
// tab: { active: 'bg-neutral-100 dark:bg-neutral-700' },
// },
wrapper:
'w-full flex flex-col sm:flex-row items-start gap-4 sm:gap-16',
list: {
width: 'w-full sm:w-48 h-fit',
background: 'bg-transparent',
tab: { active: 'bg-neutral-100 dark:bg-neutral-700' },
},
}"
>
<template #info>
@@ -226,15 +224,15 @@ const onChangePasswordSubmit = (
@submit="onEditProfileSubmit"
@change="isEditProfileModified = true"
>
<UFormField
<UFormGroup
name="username"
label="姓名"
help="您的真实姓名,将用于登录系统"
hint="账户名"
>
<UInput v-model="editProfileState.username" />
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
name="mobile"
label="手机号码"
help="手机号作为登录和找回密码的凭证,暂不支持修改"
@@ -243,27 +241,27 @@ const onChangePasswordSubmit = (
:placeholder="loginState.user?.mobile || 'nil'"
disabled
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
name="sex"
label="性别"
>
<USelect
v-model="editProfileState.sex"
:items="[
:options="[
{ label: '男', value: 1 },
{ label: '女', value: 2 },
{ label: '保密', value: 0 },
]"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
name="company"
label="公司/学校/组织名称"
help="您所在的公司或组织名称"
>
<UInput v-model="editProfileState.company" />
</UFormField>
</UFormGroup>
<div>
<UButton
@@ -276,7 +274,6 @@ const onChangePasswordSubmit = (
</UForm>
</div>
</template>
<template #security>
<div class="tab-content space-y-4">
<UForm
@@ -285,7 +282,7 @@ const onChangePasswordSubmit = (
:state="changePasswordState"
@submit="onChangePasswordSubmit"
>
<UFormField
<UFormGroup
name="old_password"
label="旧密码"
>
@@ -293,8 +290,8 @@ const onChangePasswordSubmit = (
type="password"
v-model="changePasswordState.old_password"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
name="new_password"
label="新密码"
>
@@ -302,8 +299,8 @@ const onChangePasswordSubmit = (
type="password"
v-model="changePasswordState.new_password"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
name="confirm_password"
label="确认新密码"
>
@@ -311,13 +308,13 @@ const onChangePasswordSubmit = (
type="password"
v-model="changePasswordState.confirm_password"
/>
</UFormField>
</UFormGroup>
<div>
<UButton type="submit">修改密码</UButton>
</div>
</UForm>
<!-- <USeparator /> -->
<!-- <UDivider /> -->
</div>
</template>
</UTabs>
@@ -328,9 +325,7 @@ const onChangePasswordSubmit = (
</template>
<style scoped>
@reference '@/assets/css/main.css';
.tab-content {
@apply rounded-lg bg-neutral-50 p-6 dark:bg-neutral-800;
@apply bg-neutral-50 dark:bg-neutral-800 rounded-lg p-6;
}
</style>

View File

@@ -63,7 +63,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -72,7 +72,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: res.msg || '账号或密码错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -81,7 +81,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: res.msg || '无法获取登录状态',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -104,7 +104,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
@@ -116,7 +116,7 @@ function onSubmit(form: req.user.Login) {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
@@ -138,7 +138,7 @@ const obtainSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -148,7 +148,7 @@ const obtainSmsCode = () => {
sms_counting_down.value = 60 // TODO: save timestamp to localstorage
toast.add({
title: '短信验证码已发送',
color: 'primary',
color: 'indigo',
icon: 'i-tabler-circle-check',
})
const interval = setInterval(() => {
@@ -162,7 +162,7 @@ const obtainSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: err.msg || '网络错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
@@ -183,7 +183,7 @@ const handle_sms_verify = (e: string[]) => {
toast.add({
title: '登录失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -193,7 +193,7 @@ const handle_sms_verify = (e: string[]) => {
toast.add({
title: '登录失败',
description: res.msg || '无法获取登录状态',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -216,7 +216,7 @@ const handle_sms_verify = (e: string[]) => {
toast.add({
title: '登录失败',
description: err.msg || '网络错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
@@ -259,7 +259,7 @@ const obtainForgetSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -269,7 +269,7 @@ const obtainForgetSmsCode = () => {
sms_counting_down.value = 60 // TODO: save timestamp to localstorage
toast.add({
title: '短信验证码已发送',
color: 'primary',
color: 'indigo',
icon: 'i-tabler-circle-check',
})
const interval = setInterval(() => {
@@ -283,7 +283,7 @@ const obtainForgetSmsCode = () => {
toast.add({
title: '验证码发送失败',
description: err.msg || '网络错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
@@ -307,7 +307,7 @@ const onForgetPasswordSubmit = (
toast.add({
title: '重置密码失败',
description: res.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
@@ -315,7 +315,7 @@ const onForgetPasswordSubmit = (
toast.add({
title: '重置密码成功',
description: '请您继续登录',
color: 'success',
color: 'green',
icon: 'i-tabler-circle-check',
})
currentTab.value = 1
@@ -324,7 +324,7 @@ const onForgetPasswordSubmit = (
toast.add({
title: '重置密码失败',
description: err.msg || '未知错误',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
})
@@ -353,7 +353,7 @@ onMounted(() => {
toast.add({
title: '认证失败',
description: err.msg || 'Token 或 UserID 无效',
color: 'error',
color: 'red',
icon: 'i-tabler-circle-x',
})
router.replace('/')
@@ -377,10 +377,10 @@ onMounted(() => {
<div class="flex flex-col items-center">
<UTabs
:items="items"
class="w-full sm:w-100"
class="w-full sm:w-[400px]"
v-model="currentTab"
>
<!-- <template #default="{ item, index, selected }">
<template #default="{ item, index, selected }">
<div class="flex items-center gap-2 relative truncate">
<span class="truncate">{{ item.label }}</span>
<span
@@ -388,8 +388,8 @@ onMounted(() => {
class="absolute -right-4 w-2 h-2 rounded-full bg-primary-500 dark:bg-primary-400"
/>
</div>
</template> -->
<template #content="{ item }">
</template>
<template #item="{ item }">
<UCard @submit.prevent="() => onSubmit(accountForm)">
<template #header>
<p
@@ -406,7 +406,7 @@ onMounted(() => {
v-if="item.key === 'account'"
class="space-y-3"
>
<UFormField
<UFormGroup
label="用户名"
name="username"
required
@@ -416,8 +416,8 @@ onMounted(() => {
:disabled="final_loading"
required
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="密码"
name="password"
required
@@ -428,19 +428,19 @@ onMounted(() => {
type="password"
required
/>
</UFormField>
</UFormGroup>
</div>
<div
v-else-if="item.key === 'sms'"
class="space-y-3"
>
<UFormField
<UFormGroup
label="手机号"
name="mobile"
required
>
<UFieldGroup class="w-full">
<UButtonGroup class="w-full">
<UInput
v-model="smsForm.mobile"
:disabled="final_loading"
@@ -464,11 +464,10 @@ onMounted(() => {
:loading="sms_sending"
:disabled="!!sms_counting_down || final_loading"
class="text-xs font-bold"
color="neutral"
variant="outline"
color="gray"
/>
</UFieldGroup>
</UFormField>
</UButtonGroup>
</UFormGroup>
<Transition name="pin-root">
<div
v-if="sms_triggered"
@@ -513,12 +512,12 @@ onMounted(() => {
:state="forgetPasswordState"
@submit="onForgetPasswordSubmit"
>
<UFormField
<UFormGroup
label="手机号"
name="mobile"
required
>
<UFieldGroup class="w-full">
<UButtonGroup class="w-full">
<UInput
v-model="forgetPasswordState.mobile"
:disabled="final_loading"
@@ -541,11 +540,11 @@ onMounted(() => {
:loading="sms_sending"
:disabled="!!sms_counting_down"
class="text-xs font-bold"
color="neutral"
color="gray"
/>
</UFieldGroup>
</UFormField>
<UFormField
</UButtonGroup>
</UFormGroup>
<UFormGroup
label="验证码"
name="sms_code"
required
@@ -556,8 +555,8 @@ onMounted(() => {
class="w-full"
:disabled="final_loading"
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="新密码"
name="password"
required
@@ -567,7 +566,7 @@ onMounted(() => {
type="password"
:disabled="final_loading"
/>
</UFormField>
</UFormGroup>
<div>
<UButton
@@ -587,14 +586,14 @@ onMounted(() => {
<div class="flex items-center justify-between">
<UButton
type="submit"
color="neutral"
color="black"
:loading="final_loading"
>
登录
</UButton>
<UButton
variant="link"
color="neutral"
color="gray"
@click="currentTab = 2"
>
忘记密码
@@ -607,9 +606,9 @@ onMounted(() => {
</div>
<div class="pt-4">
<UButton
color="neutral"
color="gray"
variant="ghost"
class="text-gray-500!"
class="!text-gray-500"
@click="
() => {
router.push('/user/register')

View File

@@ -48,7 +48,7 @@ const onSubmit = (form: RegisterSchema) => {
toast.add({
title: '注册成功',
description: '请联系客服激活账号后登录',
color: 'success',
color: 'green',
icon: 'i-tabler-check',
})
router.push('/user/authenticate')
@@ -59,7 +59,7 @@ const onSubmit = (form: RegisterSchema) => {
toast.add({
title: '注册失败',
description: err.message || '注册失败,请稍后再试',
color: 'error',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
@@ -78,7 +78,7 @@ const onSubmit = (form: RegisterSchema) => {
<div class="flex flex-col items-center">
<UCard
@submit.prevent="() => onSubmit(registerState)"
class="w-full sm:w-100"
class="w-full sm:w-[400px]"
>
<!-- <template #header>
<p
@@ -92,7 +92,7 @@ const onSubmit = (form: RegisterSchema) => {
</template> -->
<div class="space-y-3">
<UFormField
<UFormGroup
label="姓名"
name="username"
help="请使用姓名作为用户名,将用于登录"
@@ -103,8 +103,8 @@ const onSubmit = (form: RegisterSchema) => {
:disabled="final_loading"
required
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="密码"
name="password"
required
@@ -115,8 +115,8 @@ const onSubmit = (form: RegisterSchema) => {
type="password"
required
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="手机号"
name="mobile"
required
@@ -126,8 +126,8 @@ const onSubmit = (form: RegisterSchema) => {
:disabled="final_loading"
required
/>
</UFormField>
<UFormField
</UFormGroup>
<UFormGroup
label="公司/单位"
name="company"
required
@@ -137,14 +137,14 @@ const onSubmit = (form: RegisterSchema) => {
:disabled="final_loading"
required
/>
</UFormField>
</UFormGroup>
</div>
<template #footer>
<div class="flex items-center justify-between">
<UButton
type="submit"
color="neutral"
color="black"
:loading="final_loading"
>
注册
@@ -155,9 +155,9 @@ const onSubmit = (form: RegisterSchema) => {
</div>
<div class="pt-4">
<UButton
color="neutral"
color="gray"
variant="ghost"
class="text-gray-500!"
class="!text-gray-500"
@click="
() => {
router.push('/user/authenticate')

View File

@@ -4,8 +4,6 @@ export default defineNuxtConfig({
ssr: false,
css: ['@/assets/css/main.css'],
runtimeConfig: {
public: {
API_BASE: 'https://service1.fenshenzhike.com/',

View File

@@ -12,6 +12,7 @@
"lint:fix": "oxlint --fix",
"postinstall": "nuxt prepare"
},
"packageManager": "pnpm@10.22.0",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
@@ -31,39 +32,32 @@
"idb-keyval": "^6.2.1",
"markdown-it": "^14.1.0",
"nuxt": "^4.3.1",
"nuxt-driver.js": "^0.0.24",
"pinia": "^3.0.4",
"nuxt-driver.js": "^0.0.11",
"pinia-plugin-persistedstate": "^4.7.1",
"radix-vue": "^1.9.2",
"v-calendar": "^3.1.2",
"vue": "^3.5.28",
"vue": "^3.4.34",
"vue-router": "^4.4.0",
"yup": "^1.4.0"
},
"devDependencies": {
"@nuxt/ui": "^4.4.0",
"@nuxt/ui": "^2.20.0",
"@nuxtjs/google-fonts": "^3.2.0",
"@pinia/nuxt": "^0.11.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/typography": "^0.5.13",
"@types/markdown-it": "^13.0.9",
"@types/node": "^25.2.2",
"@vite-pwa/nuxt": "^1.1.1",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"@vueuse/core": "^14.2.0",
"@vueuse/nuxt": "^14.2.0",
"dayjs-nuxt": "^2.1.11",
"oxfmt": "^0.28.0",
"oxlint": "^1.43.0",
"sass": "^1.97.3",
"tailwindcss": "^4.1.18",
"sass": "^1.77.8",
"typescript": "^5.9.3"
},
"peerDependencies": {
"dayjs": "^1.11.19"
},
"packageManager": "pnpm@10.22.0",
"pnpm": {
"overrides": {
"citty": "0.1.6"
}
"dayjs": "^1.11.19",
"tailwindcss": "^3.4.7"
}
}

3032
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,13 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
"path": "./tsconfig.node.json"
}
]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true
}
}