refactor/upgrade-nuxt-ui #11

Merged
HoshinoSuzumi merged 2 commits from refactor/upgrade-nuxt-ui into main 2026-02-10 18:08:17 +08:00
49 changed files with 4892 additions and 6599 deletions
Showing only changes of commit 75f1987be3 - Show all commits

View File

@@ -10,7 +10,9 @@
"vitest" "vitest"
], ],
"categories": {}, "categories": {},
"rules": {}, "rules": {
"no-unused-vars": "error"
},
"settings": { "settings": {
"jsdoc": { "jsdoc": {
"ignorePrivate": false, "ignorePrivate": false,

View File

@@ -1,9 +1,9 @@
{ {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true,
// "editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
// "source.fixAll.oxc": "always" "source.fixAll.oxc": "always"
// }, },
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode" "editor.defaultFormatter": "oxc.oxc-vscode"

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import '@nuxt/ui';
@config '../../../tailwind.config.ts'; @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, The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still so we've added these compatibility styles to make sure everything still
@@ -25,3 +28,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
} }
@layer theme {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,554 +0,0 @@
<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-sm 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-xs hover:shadow-card;
@apply dark:bg-neutral-800 dark:border-neutral-600;
}
</style>

View File

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

@@ -100,6 +100,8 @@ onMounted(() => {
</template> </template>
<style> <style>
@reference '@/assets/css/main.css';
.subpage-enter-active, .subpage-enter-active,
.subpage-leave-active { .subpage-leave-active {
@apply transition-all duration-300; @apply transition-all duration-300;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@
"yup": "^1.4.0" "yup": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/ui": "^2.20.0", "@nuxt/ui": "^3.3.7",
"@nuxtjs/google-fonts": "^3.2.0", "@nuxtjs/google-fonts": "^3.2.0",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",

2008
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff