20 Commits

Author SHA1 Message Date
fa756d8c97 refactor: 迁移到 @nuxt/ui@4
- https://ui.nuxt.com/docs/getting-started/migration/v4#changes-from-v3
2026-02-10 21:51:21 +08:00
8198c853c9 chore(deps): 更新依赖,修复 peerDeps 关系 2026-02-10 21:39:55 +08:00
4081288a10 Merge pull request 'refactor/upgrade-nuxt-ui' (#11) from refactor/upgrade-nuxt-ui into main 2026-02-10 18:08:16 +08:00
75f1987be3 refactor!: 升级 @nuxt/ui@3,重构所有页面和组件,调整配置,移除不在需求中的页面 2026-02-10 18:07:44 +08:00
d0bca215c1 chore(deps): bump tailwindcss to v4 2026-02-10 10:29:50 +08:00
2c4951ff9b chore(config): 更新格式化和 lint 配置,格式化部分文件 2026-02-10 02:03:35 +08:00
3c98ee0d47 chore(tsconfig): 删除不再使用的 tsconfig.node.json 文件 2026-02-10 01:28:30 +08:00
8d6e2efb53 chore(persist): 修复持久化状态 pick 字段 2026-02-10 01:18:02 +08:00
377c36f846 chore(tsconfig): 修复自动导入引用路径 2026-02-10 01:17:42 +08:00
11581ffad6 fix(typings): 类型移动到 app 目录 2026-02-10 00:47:18 +08:00
9cc21a0107 chore(deps): update deps 2026-02-10 00:42:07 +08:00
5fe7b7f788 Merge pull request 'chore/migrate-to-nuxt4' (#6) from chore/migrate-to-nuxt4 into main 2026-02-10 00:31:46 +08:00
880b85f75d refactor(deps): migrate to nuxt v4 2026-02-10 00:31:04 +08:00
f1b9cea060 chore(deps): 升级 Nuxt 版本至 4.3.1 2026-02-10 00:09:19 +08:00
6a4685f588 chore: 更新项目名称和版本号
Some checks are pending
aigc/aigc_next_multi/pipeline/pr-main Build queued...
aigc/aigc_next_multi/pipeline/head This commit looks good
2026-02-10 00:01:59 +08:00
133c5c661b chore(deps): replace pinia-plugin-persistedstate 2026-02-09 23:55:11 +08:00
6a54ecd003 chore(ide): add Node.js version file for version management
Some checks are pending
aigc/aigc_next_multi/pipeline/pr-main Build queued...
aigc/aigc_next_multi/pipeline/head This commit looks good
2026-02-09 23:53:06 +08:00
8dea27d14a ci: 更新 Jenkinsfile,添加部署参数和构建条件
Some checks are pending
aigc/aigc_next_multi/pipeline/pr-main Build queued...
aigc/aigc_next_multi/pipeline/head This commit looks good
2026-02-09 14:08:10 +08:00
96ddb09ea3 ci: 添加 Lint 阶段到 Jenkinsfile
All checks were successful
aigc/aigc_next_multi/pipeline/pr-main This commit looks good
aigc/aigc_next_multi/pipeline/head This commit looks good
aigc/aigc_next_multi/pipeline/release This commit looks good
2026-02-09 13:37:47 +08:00
4865ca8392 ci: add Jenkinsfile
Some checks are pending
aigc/aigc_next_multi/pipeline/pr-main Build queued...
aigc/aigc_next_multi/pipeline/head This commit looks good
2026-02-09 13:20:19 +08:00
106 changed files with 11645 additions and 13208 deletions

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v22.22.0

View File

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

View File

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

View File

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

74
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,74 @@
pipeline {
agent any
options {
timestamps()
}
parameters {
choice(
name: 'XSH_DEPLOY_TARGET',
choices: ['main'],
description: 'main眩生花线上版本'
)
booleanParam(
name: 'XSH_DEPLOY_TO_PRODUCTION',
defaultValue: true,
description: '是否自动部署到线上环境(否则只构建产物)\n* 仅在 main 分支生效'
)
}
tools {
nodejs 'NodeJS 22.22'
}
libraries {
lib('xsh-common@main')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Lint') {
steps {
sh '''
corepack enable
pnpm install --registry=https://registry.npmmirror.com
pnpm run lint
'''
}
}
stage('Build') {
when {
allOf {
expression { currentBuild.currentResult == 'SUCCESS' }
expression { env.TAG_NAME != null }
}
}
steps {
sh '''
corepack enable
pnpm install --registry=https://registry.npmmirror.com
pnpm run generate
'''
}
}
}
post {
always {
echo "Build finished: ${currentBuild.currentResult}"
}
success {
archiveArtifacts allowEmptyArchive: true, artifacts: 'dist/**', followSymlinks: true, onlyIfSuccessful: true
}
failure {
echo "Build failed"
}
}
}

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

33
app/assets/css/main.css Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 411 KiB

View File

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

View File

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

View File

@@ -0,0 +1,560 @@
<script lang="ts" setup>
import { object, string } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
const toast = useToast()
const loginState = useLoginState()
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// 表单状态
const formState = reactive({
dh_name: '',
organization: '',
})
// 文件上传状态
const videoFile = ref<File | null>(null)
const authVideoFile = ref<File | null>(null)
const isSubmitting = ref(false)
// 上传进度状态
const uploadProgress = reactive({
step: 0,
total: 3,
message: '',
})
// 表单验证
const schema = object({
dh_name: string()
.required('请输入数字人名称')
.max(50, '数字人名称不能超过50个字符'),
organization: string()
.required('请输入单位名称')
.max(100, '单位名称不能超过100个字符'),
})
// 更新上传进度
const updateProgress = (step: number, message: string) => {
uploadProgress.step = step
uploadProgress.message = message
}
// 处理数字人视频上传
const handleVideoUpload = (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!['video/mp4', 'video/mov'].includes(file.type)) {
toast.add({
title: '文件格式错误',
description: '仅支持MP4和MOV格式的视频文件',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小 (1GB)
if (file.size > 1024 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过1GB',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
videoFile.value = file
toast.add({
title: '文件上传成功',
description: '数字人视频已选择',
color: 'success',
icon: 'i-tabler-check',
})
}
// 处理授权视频上传
const handleAuthVideoUpload = (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!['video/mp4', 'video/mov'].includes(file.type)) {
toast.add({
title: '文件格式错误',
description: '仅支持MP4和MOV格式的视频文件',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小
if (file.size > 1024 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过1GB',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
authVideoFile.value = file
toast.add({
title: '文件上传成功',
description: '授权视频已选择',
color: 'success',
icon: 'i-tabler-check',
})
}
// 提交表单
const onSubmit = async (event: FormSubmitEvent<typeof formState>) => {
// 验证文件是否已上传
if (!videoFile.value) {
toast.add({
title: '请上传数字人视频素材',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
if (!authVideoFile.value) {
toast.add({
title: '请上传形象授权视频',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
if (isSubmitting.value) return
try {
isSubmitting.value = true
updateProgress(0, '开始创建数字人...')
// 上传数字人视频素材
updateProgress(1, '上传数字人视频素材...')
const videoUrl = await useFileGo(videoFile.value, 'material')
// 上传形象授权视频
updateProgress(2, '上传形象授权视频...')
const authVideoUrl = await useFileGo(authVideoFile.value, 'material')
// 创建数字人定制记录
updateProgress(3, '创建数字人定制记录...')
const response = await useFetchWrapped<
{
user_id: number
dh_name: string
organization: string
video_url: string
auth_video_url: string
} & AuthedRequest,
BaseResponse<{ train_id: number }>
>('App.Digital_Train.Create', {
token: loginState.token!,
user_id: loginState.user.id,
dh_name: event.data.dh_name,
organization: event.data.organization,
video_url: videoUrl,
auth_video_url: authVideoUrl,
})
if (response.ret === 200 && response.data.train_id) {
toast.add({
title: '数字人定制提交成功',
description: '您的数字人定制请求已提交,请等待管理员处理',
color: 'success',
icon: 'i-tabler-check',
})
// 重置表单
formState.dh_name = ''
formState.organization = ''
videoFile.value = null
authVideoFile.value = null
uploadProgress.step = 0
uploadProgress.message = ''
// 关闭弹窗
isOpen.value = false
} else {
throw new Error(response.msg || '创建失败')
}
} catch (error) {
console.error('数字人定制失败:', error)
const errorMessage =
error instanceof Error ? error.message : '数字人定制失败,请重试'
toast.add({
title: '提交失败',
description: errorMessage,
color: 'error',
icon: 'i-tabler-alert-triangle',
})
} finally {
isSubmitting.value = false
uploadProgress.step = 0
uploadProgress.message = ''
}
}
// 重置表单
const resetForm = () => {
formState.dh_name = ''
formState.organization = ''
videoFile.value = null
authVideoFile.value = null
uploadProgress.step = 0
uploadProgress.message = ''
}
// 监听弹窗关闭事件,重置表单
watch(isOpen, (newValue) => {
if (!newValue) {
resetForm()
}
})
// 显示授权文案弹窗
const showAuthModal = ref(false)
</script>
<template>
<UModal
v-model:open="isOpen"
:ui="{ content: 'sm:max-w-6xl' }"
>
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
数字人定制
</h3>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="isOpen = false"
/>
</div>
</template>
<div class="grid grid-cols-7 gap-6">
<!-- 左侧表单 -->
<div class="col-span-3 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<UForm
:schema="schema"
:state="formState"
class="space-y-4"
@submit="onSubmit"
>
<!-- 数字人视频素材 -->
<UFormField
label="数字人视频素材"
required
>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-video-camera"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
videoFile ? videoFile.name : '点击或拖拽上传视频'
}}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
小于 1GB mov/mp4 格式比例 9:16帧率 25FPS分辨率
1080P时长 3-6 分钟
</p>
</div>
</template>
</UniFileDnD>
</UFormField>
<!-- 数字人名称 -->
<UFormField
label="数字人名称"
name="dh_name"
required
>
<UInput
v-model="formState.dh_name"
placeholder="请输入数字人名称"
/>
</UFormField>
<!-- 单位名称 -->
<UFormField
label="单位名称"
name="organization"
required
>
<UInput
v-model="formState.organization"
placeholder="请输入单位名称"
/>
</UFormField>
<!-- 形象授权视频 -->
<UFormField
label="形象授权视频"
required
>
<template #description>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">
请确保本人进行形象授权视频录制否则脸部比对将不通过导致制作失败
</span>
<UButton
variant="link"
size="xs"
icon="i-heroicons-document-text"
@click="showAuthModal = true"
>
授权文案
</UButton>
</div>
</template>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleAuthVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-shield-check"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
authVideoFile
? authVideoFile.name
: '点击或拖拽上传授权视频'
}}
</span>
</div>
</div>
</template>
</UniFileDnD>
</UFormField>
<!-- 提交按钮 -->
<UButton
type="submit"
class="w-full"
:loading="isSubmitting"
:disabled="isSubmitting"
color="primary"
>
{{ isSubmitting ? '提交中...' : '确认提交' }}
</UButton>
<!-- 上传进度 -->
<div
v-if="isSubmitting"
class="mt-4 space-y-2"
>
<div class="flex justify-between text-sm">
<span>{{ uploadProgress.message }}</span>
<span>
{{ uploadProgress.step }}/{{ uploadProgress.total }}
</span>
</div>
<UProgress
:value="(uploadProgress.step / uploadProgress.total) * 100"
color="primary"
/>
</div>
</UForm>
</div>
<!-- 右侧教程和提示 -->
<div class="col-span-4 rounded-lg border p-4 dark:border-gray-700">
<div class="flex h-full flex-col gap-6">
<!-- 教程视频 -->
<div class="flex-1">
<h3
class="mb-3 flex items-center gap-2 text-lg font-semibold text-gray-800 dark:text-white"
>
<UIcon
name="i-heroicons-video-camera"
class="h-5 w-5"
/>
视频录制教程
</h3>
<div
class="flex aspect-video w-full items-center justify-center rounded-lg border bg-gray-100 dark:bg-gray-800"
>
<UIcon
name="i-heroicons-video-camera"
class="h-12 w-12 text-gray-400"
/>
</div>
</div>
<!-- 联系方式 -->
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/20"
>
<div class="flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900">
<UIcon
name="i-heroicons-chat-bubble-left-right"
class="h-5 w-5 text-blue-600 dark:text-blue-400"
/>
</div>
<div>
<p
class="text-sm font-medium text-gray-800 dark:text-white"
>
需要帮助
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
客服微信
<span class="font-mono text-blue-600 dark:text-blue-400">
xxxxxx
</span>
</p>
</div>
</div>
</div>
<!-- 录制指南 -->
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/20"
>
<div class="flex items-start gap-3">
<div
class="mt-0.5 rounded-lg bg-amber-100 p-2 dark:bg-amber-900"
>
<UIcon
name="i-heroicons-light-bulb"
class="h-5 w-5 text-amber-600 dark:text-amber-400"
/>
</div>
<div class="flex-1">
<h4
class="mb-3 text-sm font-semibold text-gray-800 dark:text-white"
>
录制注意事项
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-sun"
class="mt-0.5 h-4 w-4 shrink-0 text-amber-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
确保光线充足避免背光
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-speaker-wave"
class="mt-0.5 h-4 w-4 shrink-0 text-green-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
选择安静环境减少噪音干扰
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-viewfinder-circle"
class="mt-0.5 h-4 w-4 shrink-0 text-blue-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
人脸占画面比例控制在 1/4 以内
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-face-smile"
class="mt-0.5 h-4 w-4 shrink-0 text-purple-500"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
保持自然表情使用恰当手势
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
<!-- 授权文案弹窗 -->
<UModal v-model:open="showAuthModal">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
授权视频文案
</h3>
<UButton
color="neutral"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="showAuthModal = false"
/>
</div>
</template>
<div class="p-4">
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
请确保您是视频中人物的合法授权人在授权视频中朗读以下文案
</p>
<div class="rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
<p
class="text-sm leading-relaxed text-gray-800 dark:text-gray-200"
>
我是在"AI智慧职教平台"定制上传视频的模特本人我承诺已经按照平台规则进行合法授权特此承诺
</p>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
</UModal>
</template>
<style scoped></style>

View File

@@ -18,12 +18,12 @@ const props = defineProps({
<template>
<div
:class="{
'w-full h-[1px]': !vertical,
'w-[1px] h-full': vertical,
'w-full h-px': !vertical,
'w-px h-full': vertical,
[`from-${lineGradientFrom}-500/50`]: true,
[`to-${lineGradientTo}-300/50`]: true,
}"
class="bg-gradient-to-r rounded-full my-4"
class="bg-linear-to-r rounded-full my-4"
></div>
</template>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,317 @@
<script lang="ts" setup>
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const props = defineProps({
isOpen: {
type: Boolean,
required: false,
},
multiple: {
type: Boolean,
default: false,
},
disabledDigitalHumanIds: {
type: Array,
default: () => [],
},
defaultTab: {
type: String as PropType<'user' | 'system'>,
default: 'user',
},
})
const emit = defineEmits({
close: () => true,
select: (digitalHumans: DigitalHumanItem | DigitalHumanItem[]) =>
digitalHumans,
})
const loginState = useLoginState()
const toast = useToast()
const page = ref(1)
const isRealOpen = computed(() => props.isOpen)
const sourceTypeList = [
{ label: 'xsh_wm', value: 1, color: 'info' }, // 万木(腾讯)
{ label: 'xsh_zy', value: 2, color: 'success' }, // XSH 自有
{ label: 'xsh_fh', value: 3, color: 'warning' }, // 硅基(泛化数字人)
{ label: 'xsh_bb', value: 4, color: 'primary' }, // 百度小冰
]
// const sourceType = ref(sourceTypeList[0])
const selectedDigitalHumans = ref<DigitalHumanItem[]>([])
const handleSelectClick = (item: DigitalHumanItem) => {
// 如果点击的项目已经在已选列表中,则移除;否则添加
if (selectedDigitalHumans.value.includes(item)) {
selectedDigitalHumans.value = selectedDigitalHumans.value.filter(
(d) => d !== item
)
} else {
selectedDigitalHumans.value = props.multiple
? [...selectedDigitalHumans.value, item]
: [item]
}
}
const handleClose = () => {
selectedDigitalHumans.value = []
if (props.isOpen) {
emit('close')
} else {
emit('close')
}
}
const handleSubmit = () => {
if (selectedDigitalHumans.value.length === 0) {
toast.add({
title: '请选择数字人',
description: '请至少选择一个数字人',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
}
emit(
'select',
props.multiple
? selectedDigitalHumans.value
: selectedDigitalHumans.value[0]!
)
handleClose()
setTimeout(() => {
page.value = 1
}, 300)
}
const tabItems = [
{
key: 'user',
label: '我的数字人',
icon: 'i-tabler-user',
},
]
const tabIndex = ref(0)
watch(tabIndex, () => {
page.value = 1
})
const { data: userDigitalList } = useAsyncData(
'user-digital-human',
() =>
useFetchWrapped<
req.gen.DigitalHumanList & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.User_UserDigital.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 15,
// source_type: sourceType.value.value,
}),
{
watch: [page],
}
)
const { data: systemDigitalList } = useAsyncData(
'system-digital-human',
() =>
useFetchWrapped<
req.gen.DigitalHumanList & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.Digital_Human.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 15,
// source_type: sourceType.value.value,
}),
{
watch: [page],
}
)
onMounted(() => {
if (loginState.user.auth_code === 2) {
tabItems.push({
key: 'system',
label: '系统数字人',
icon: 'i-tabler-user-star',
})
nextTick(() => {
tabIndex.value = tabItems.findIndex((i) => i.key === props.defaultTab)
console.log('tabIndex', tabIndex.value)
})
}
})
</script>
<template>
<UModal
v-model:open="isRealOpen"
:ui="{ content: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
数字人选择器
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<UTabs
v-model="tabIndex"
:items="tabItems"
>
<template #content="{ item }">
<div class="grid w-full grid-cols-3 gap-4 sm:grid-cols-5">
<div
v-for="(d, i) in item.key === 'user'
? userDigitalList?.data.items
: systemDigitalList?.data.items"
:key="`${item.key === 'user' ? 'user' : 'system'}-digital-${
d.model_id
}`"
:class="{
'border-primary shadow-md': selectedDigitalHumans.includes(d),
'border-neutral-200 dark:border-neutral-700':
!selectedDigitalHumans.includes(d),
}"
class="relative flex w-full cursor-pointer select-none flex-col items-center justify-center gap-2 overflow-hidden rounded-md border bg-white transition-all duration-150 dark:border-2 dark:bg-neutral-800"
@click="
!disabledDigitalHumanIds.includes(d.model_id)
? handleSelectClick(d)
: void 0
"
>
<div
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute inset-0 z-10 cursor-not-allowed bg-neutral-400/50 dark:bg-neutral-700/50"
></div>
<div
:class="{
'bg-primary-50': selectedDigitalHumans.includes(d),
}"
class="relative aspect-square w-full overflow-hidden border-b bg-neutral-100 object-cover transition-all duration-150 dark:border-neutral-700 dark:bg-neutral-800"
>
<NuxtImg
:src="d.avatar"
class="-translate-y-4"
/>
<UIcon
v-if="selectedDigitalHumans.includes(d)"
class="text-primary absolute right-1 top-1 text-lg"
name="i-tabler-check"
/>
<UIcon
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute right-1 top-1 text-lg text-red-500"
name="tabler:user-off"
/>
<template
v-for="(t, i) in sourceTypeList"
:key="i"
>
<UBadge
v-if="t.value === d.type"
class="absolute bottom-1 right-1"
size="xs"
variant="subtle"
:color="t.color as any"
:label="t.label"
/>
</template>
</div>
<div class="flex w-full flex-col gap-1 px-2 pb-2">
<div class="flex items-center justify-between">
<span
class="line-clamp-1 text-sm font-medium text-neutral-800 dark:text-neutral-300"
>
{{ d.name }}
</span>
<span
class="text-xs font-medium text-neutral-300 dark:text-neutral-500"
>
ID:{{ d.digital_human_id || d.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex items-end justify-between">
<div class="flex items-center gap-2">
<!-- <span class="text-sm text-neutral-800 dark:text-neutral-300 font-medium">
选择来源:
</span>
<USelectMenu
v-model="sourceType"
:options="sourceTypeList.map(i => ({ label: i.label, value: i.value }))"
@change="page = 1"
/> -->
</div>
<UPagination
v-if="
(item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0) > 0
"
v-model:page="page"
:page-count="15"
:total="
item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0
"
class="pt-4"
/>
</div>
</template>
</UTabs>
<template #footer>
<div class="flex items-center justify-between">
<div>
<p class="select-none text-xs font-medium opacity-50">
如果没有出现您的数字人,请联系管理员开通
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="neutral"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
const props = defineProps({
isOpen: {
type: Boolean,
required: false,
},
})
const emit = defineEmits({
close: () => true,
select: (titles: TitlesTemplate) => titles,
})
const toast = useToast()
const loginState = useLoginState()
const isRealOpen = computed(() => props.isOpen)
const pagination = reactive({
page: 1,
pageSize: 15,
})
const selectedTitle = ref<TitlesTemplate | null>(null)
const {
data: userTitlesTemplate,
status: userTitlesTemplateStatus,
refresh: refreshUserTitlesTemplate,
} = useAsyncData(
'userTitlesTemplate',
() =>
useFetchWrapped<
PagedDataRequest & AuthedRequest & { process_status: 0 | 1 },
BaseResponse<PagedData<TitlesTemplate>>
>('App.User_UserTitles.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: pagination.page,
perpage: pagination.pageSize,
process_status: 1,
}),
{
watch: [pagination],
}
)
const handleClose = () => {
if (props.isOpen) {
emit('close')
} else {
emit('close')
}
}
const handleSubmit = () => {
if (!selectedTitle.value) {
toast.add({
title: '请选择片头',
description: '请选择一个片头',
color: 'error',
icon: 'i-tabler-circle-x',
})
return
}
emit('select', selectedTitle.value)
handleClose()
}
</script>
<template>
<UModal
v-model:open="isRealOpen"
:ui="{ content: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
视频片头选择器
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<div>
<div class="grid w-full grid-cols-2 gap-4 sm:grid-cols-3">
<div
v-for="(titles, i) in userTitlesTemplate?.data.items"
:key="`user-titles-${titles.id}`"
:class="{
'border-primary shadow-md': selectedTitle?.id === titles.id,
'border-neutral-200 dark:border-neutral-700':
selectedTitle?.id !== titles.id,
}"
class="relative flex w-full cursor-pointer select-none flex-col items-center justify-center gap-2 overflow-hidden rounded-md border bg-white transition-all duration-150 dark:border-2 dark:bg-neutral-800"
@click="selectedTitle = titles"
>
<div
:class="{
'bg-primary-50': selectedTitle?.id === titles.id,
}"
class="relative aspect-video w-full overflow-hidden border-b bg-neutral-100 object-cover transition-all duration-150 dark:border-neutral-700 dark:bg-neutral-800"
>
<NuxtImg :src="titles.opening_url" />
<UIcon
v-if="selectedTitle?.id === titles.id"
class="text-primary absolute right-1 top-1 text-lg"
name="i-tabler-check"
/>
</div>
<div class="flex w-full flex-col gap-1 px-2 pb-2">
<div class="flex items-center justify-between">
<span
class="line-clamp-1 text-sm font-medium text-neutral-800 dark:text-neutral-300"
>
{{ titles.title }}
</span>
<span
class="text-xs font-medium text-neutral-300 dark:text-neutral-500"
>
ID:{{ titles.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<UPagination
v-if="(userTitlesTemplate?.data.total || 0) > 0"
v-model:page="pagination.page"
:page-count="pagination.pageSize"
:total="userTitlesTemplate?.data.total || 0"
class="pt-4"
/>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between">
<div>
<p class="select-none text-xs font-medium opacity-50">
如果此处没有您的片头请在
<a
class="text-primary"
href="/generation/materials"
target="_blank"
>
片头模版库
</a>
页面确认已经制作完毕
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="neutral"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<style scoped></style>

View File

@@ -0,0 +1,350 @@
<script lang="ts" setup>
import FileDnD from '~/components/uni/FileDnD/index.vue'
import { type InferType, number, object, string } from 'yup'
import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success', 'close'])
const toast = useToast()
const loginState = useLoginState()
const creationForm = ref<HTMLFormElement>()
const creationPending = ref(false)
const isDigitalSelectorOpen = ref(false)
const isTitlesSelectorOpen = ref(false)
const createCourseSchema = object({
task_title: string()
.trim()
.min(4, '标题必须大于4个字符')
.max(20, '标题不能超过20个字符')
.required('请输入微课标题'),
digital_human_id: number().not([0], '请选择数字人'),
opening_url: string().url().notRequired().default(''),
ending_url: string().url().notRequired().default(''),
gen_server: string().required(),
speed: number().default(1.0).min(0.5).max(1.5).required(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
task_title: undefined,
digital_human_id: 0,
opening_url: '',
ending_url: '',
gen_server: 'main',
speed: 1.0,
})
const selected_file = ref<File[] | null>(null)
const selected_digital_human = ref<DigitalHumanItem | null>(null)
const selected_titles = ref<TitlesTemplate | null>(null)
watchEffect(() => {
if (selected_digital_human.value) {
// 2025.03.31 使用内部数字人 ID
createCourseState.digital_human_id =
selected_digital_human.value.digital_human_id ??
selected_digital_human.value.id ??
0
}
if (selected_titles.value) {
createCourseState.opening_url = selected_titles.value.opening_file
createCourseState.ending_url = selected_titles.value.ending_file
}
})
const onCreateCourseSubmit = async (
event: FormSubmitEvent<CreateCourseSchema>
) => {
if (!selected_file.value) {
toast.add({
title: '未选择文件',
description: '请先选择 PPTX 文件',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
creationPending.value = true
// upload PPTX file
useFileGo(selected_file.value[0]!, 'ppt').then((url) => {
useFetchWrapped<
req.gen.CourseGenCreate & AuthedRequest,
BaseResponse<resp.gen.CourseGenCreate>
>('App.Digital_Convert.Create', {
token: loginState.token!,
user_id: loginState.user.id,
task_title: event.data.task_title,
gen_server: event.data.gen_server as 'main' | 'standby1',
speed: event.data.speed,
ppt_url: url,
digital_human_id: event.data.digital_human_id,
custom_video: '[]',
opening_url: event.data.opening_url || '',
ending_url: event.data.opening_url || '',
})
.then((res) => {
if (res.data.record_status === 1) {
toast.add({
title: '创建成功',
description: '已加入生成队列',
color: 'success',
icon: 'i-tabler-check',
})
emit('success')
emit('close')
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
})
.catch((e) => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
})
})
}
</script>
<template>
<USlideover
:dismissible="false"
title="新建微课视频"
>
<template #content>
<UCard
:ui="{
body: 'flex-1',
}"
class="flex flex-1 flex-col"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建微课视频
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="$emit('close')"
/>
</div>
</template>
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormField
label="微课标题"
name="task_title"
required
>
<UInput
v-model="createCourseState.task_title"
placeholder="请输入微课标题"
/>
</UFormField>
</div>
<div class="grid grid-cols-2 gap-2">
<UFormField
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
@click="isDigitalSelectorOpen = true"
>
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</UFormField>
<UFormField
label="视频片头片尾"
name="opening"
>
<div
:class="{ 'shadow-inner': !!selected_titles }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
@click="isTitlesSelectorOpen = true"
>
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
>
<UIcon
v-if="!selected_titles"
class="text-2xl opacity-50"
name="i-tabler-brackets-contain"
/>
<NuxtImg
v-else
:src="selected_titles?.opening_url"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<span :class="!!selected_titles ? 'text-neutral-600' : ''">
{{ selected_titles?.title || '点击选择片头' }}
</span>
<span
v-if="selected_titles?.description"
class="text-2xs"
>
{{ selected_titles?.description }}
</span>
</div>
</div>
</UFormField>
</div>
<UFormField
label="PPT 文件"
required
>
<template #help>
<p class="text-xs text-neutral-400">仅支持 .pptx 格式</p>
</template>
<FileDnD
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
@change="(file) => (selected_file = file)"
/>
</UFormField>
<UAccordion
:items="[{ label: '高级选项' }]"
color="neutral"
size="lg"
>
<template #content>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<UFormField
label="生成线路"
name="gen_server"
>
<USelectMenu
v-model="createCourseState.gen_server"
:items="[
{
label: '主线路',
value: 'main',
},
{
label: '备用线路',
value: 'standby1',
},
]"
value-key="value"
/>
</UFormField>
<UFormField
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<USlider
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormField>
</div>
</template>
</UAccordion>
</UForm>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
<ModalVideoTitleSelect
:is-open="isTitlesSelectorOpen"
@close="isTitlesSelectorOpen = false"
@select="
(titles) => {
selected_titles = titles as TitlesTemplate
}
"
/>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="neutral"
label="取消"
size="lg"
variant="ghost"
@click="$emit('close')"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
</template>
</USlideover>
</template>
<style scoped></style>

View File

@@ -0,0 +1,300 @@
<script lang="ts" setup>
import { type InferType, number, object, string } from 'yup'
import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success', 'close'])
const toast = useToast()
const loginState = useLoginState()
const creationForm = ref<HTMLFormElement>()
const creationPending = ref(false)
const isDigitalSelectorOpen = ref(false)
const createCourseSchema = object({
title: string()
.trim()
.min(4, '标题必须大于4个字符')
.max(20, '标题不能超过20个字符')
.required('请输入视频标题'),
content: string()
.trim()
.min(4, '内容必须大于4个字符')
.max(1000, '内容不能超过1000个字符')
.required('请输入驱动文本内容'),
digital_human_id: number().not([0], '请选择数字人'),
source_type: number().default(0).required(),
speed: number().default(1.0).min(0.5).max(1.5).required(),
bg_img: string().optional(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
title: undefined,
content: undefined,
digital_human_id: 0,
source_type: 0,
speed: 1.0,
bg_img: '',
})
const selected_digital_human = ref<DigitalHumanItem | null>(null)
const selected_bg_img = ref<File | undefined>()
const enableBackgroundCompositing = ref(false)
watchEffect(() => {
if (selected_digital_human.value) {
// 2025.02.26 使用内部数字人 ID
createCourseState.digital_human_id =
selected_digital_human.value.digital_human_id ??
selected_digital_human.value.id ??
0
createCourseState.source_type = selected_digital_human.value.type!
}
})
watchEffect(() => {
// 根据背景合成开关更新 bg_img
createCourseState.bg_img = enableBackgroundCompositing.value
? 'https://service1.fenshenzhike.com/default_background.png'
: ''
})
const onCreateCourseGreenSubmit = async (
event: FormSubmitEvent<CreateCourseSchema>
) => {
creationPending.value = true
let payload: {
token: string
user_id: number
title: string
content: string
digital_human_id: any
speed: number
device_id: string
source_type: 1 | 2 | undefined
bg_img?: string
} = {
token: loginState.token!,
user_id: loginState.user.id,
title: event.data.title,
content: event.data.content,
digital_human_id: event.data.digital_human_id,
speed: 2 - event.data.speed,
device_id: 'XSHAssistant Web',
source_type: event.data.source_type as 1 | 2 | undefined,
bg_img: event.data.bg_img,
}
useFetchWrapped<
req.gen.GBVideoCreate & AuthedRequest,
BaseResponse<resp.gen.GBVideoCreate>
>('App.Digital_VideoTask.Create', payload)
.then((res) => {
if (res.data.task_id) {
toast.add({
title: '创建成功',
description: '视频已加入生成队列',
color: 'success',
icon: 'i-tabler-check',
})
emit('success')
emit('close')
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
})
.catch((e) => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
})
}
</script>
<template>
<USlideover :dismissible="false">
<template #content>
<UCard
:ui="{
body: 'flex-1',
}"
class="flex flex-1 flex-col"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建绿幕视频
</h3>
<UButton
class="-my-1"
color="neutral"
icon="i-tabler-x"
variant="ghost"
@click="emit('close')"
/>
</div>
</template>
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseGreenSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormField
label="视频标题"
name="title"
required
>
<UInput
v-model="createCourseState.title"
placeholder="请输入视频标题"
/>
</UFormField>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<UFormField
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex cursor-pointer select-none items-center gap-2 rounded-md bg-neutral-100 p-2 transition-all dark:bg-neutral-800"
@click="isDigitalSelectorOpen = true"
>
<div
class="flex aspect-square w-12 items-center justify-center overflow-hidden rounded-md border dark:border-neutral-700"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-sm font-medium text-neutral-400">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</UFormField>
</div>
<!-- <UFormField label="背景图片" name="bg_img" help="可以上传图片作为视频背景,留空则为绿幕背景">
<UInput type="file" accept="image/jpg,image/png" placeholder="选择背景图片" @change="selected_bg_img = $event?.[0] || undefined"/>
</UFormField> -->
<UFormField
label="驱动内容"
name="content"
required
>
<UTextarea
v-model="createCourseState.content"
:rows="6"
autoresize
placeholder="请输入驱动文本内容"
/>
</UFormField>
<UFormField
label="启用背景合成"
name="bg_img"
help="开启后生成透明通道,可在视频生成完毕后选择自定义背景合成;关闭则使用绿幕背景。"
>
<USwitch v-model="enableBackgroundCompositing" />
</UFormField>
<UAccordion
:items="[{ label: '高级选项' }]"
color="gray"
size="lg"
>
<template #content>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<UFormField
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<USlider
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormField>
</div>
</template>
</UAccordion>
</UForm>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="neutral"
label="取消"
size="lg"
variant="ghost"
@click="emit('close')"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
</template>
</USlideover>
</template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,626 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import { encode } from '@monosky/base64'
import { object, string, number, type InferType } from 'yup'
interface Subtitle {
start: string
end: string
text: string
active?: boolean
}
const props = defineProps({
course: {
type: Object as PropType<resp.gen.CourseGenItem>,
required: true,
},
})
const dayjs = useDayjs()
const toast = useToast()
const loginState = useLoginState()
const isDrawerActive = ref(false)
const isLoading = ref(true)
const isSaving = ref(false)
const rawSrt = ref<string | null>(null)
const subtitles = ref<Subtitle[]>([])
const modified = ref(false)
const isExporting = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
const subtitleStyleSchema = object({
color: string().required(),
fontSize: number().required(),
effect: string().required(),
bottomOffset: number().required(),
})
type subtitleStyleSchema = InferType<typeof subtitleStyleSchema>
const subtitleStyleState = reactive<subtitleStyleSchema>({
color: '#fff',
effect: 'shadow-xs',
fontSize: 24,
bottomOffset: 12,
})
const loadSrt = async () => {
isLoading.value = true
try {
// const response = await fetch(props.course.subtitle_url)
const response = await fetch(await fetchCourseSubtitleUrl(props.course))
const text = await response.text()
rawSrt.value = text
parseSrt(text)
} catch (err) {
toast.add({
title: '加载字幕失败',
description: `${err}` || '未知错误',
color: 'error',
})
} finally {
isLoading.value = false
}
}
const parseSrt = (srt: string) => {
const lines = srt.split(/\r?\n/)
const regex = /(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/
let subtitle: Subtitle | null = null
lines.forEach((line) => {
if (/^\d+$/.test(line.trim())) return
const match = line.match(regex)
if (match) {
if (subtitle) {
subtitles.value.push(subtitle)
}
subtitle = {
start: match[1] || '',
end: match[2] || '',
text: '',
}
} else if (subtitle) {
subtitle.text += line.trim() ? line : ''
}
})
if (subtitle) {
subtitles.value.push(subtitle)
}
}
const generateSrt = () => {
return subtitles.value
.map((subtitle, index) => {
return `${index + 1}\n${subtitle.start} --> ${subtitle.end}\n${
subtitle.text
}\n`
})
.join('\n')
}
const formatTime = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0]?.split(':') || []
return {
hours: parseInt(timeParts[0] || '0'),
minutes: parseInt(timeParts[1] || '0'),
seconds: parseInt(timeParts[2] || '0'),
milliseconds: parseInt(parts[1] || '0'),
}
}
const formatTimeToDayjs = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0]?.split(':') || []
return dayjs()
.hour(parseInt(timeParts[0] || '0'))
.minute(parseInt(timeParts[1] || '0'))
.second(parseInt(timeParts[2] || '0'))
.millisecond(parseInt(parts[1] || '0'))
}
const syncSubtitles = () => {
if (!videoElement.value) return
const currentTime = videoElement.value.currentTime * 1000 // convert to milliseconds
subtitles.value.forEach((subtitle) => {
const start = formatTime(subtitle.start)
const end = formatTime(subtitle.end)
const startTime =
(start.hours * 3600 + start.minutes * 60 + start.seconds) * 1000 +
start.milliseconds
const endTime =
(end.hours * 3600 + end.minutes * 60 + end.seconds) * 1000 +
end.milliseconds
subtitle.active = currentTime >= startTime && currentTime <= endTime
// scroll active subtitle into view
if (subtitle.active) {
const element = document.getElementById(
`subtitle-${subtitles.value.indexOf(subtitle)}`
)!
const parent = element?.parentElement
// scroll element to the center of parent
parent?.scrollTo({
top: element.offsetTop,
})
}
})
}
const onSubtitleInputClick = (subtitle: Subtitle) => {
if (!videoElement.value) return
if (!subtitle.active) {
videoElement.value.currentTime =
formatTime(subtitle.start).hours * 3600 +
formatTime(subtitle.start).minutes * 60 +
formatTime(subtitle.start).seconds +
1
}
videoElement.value.pause()
}
const saveNewSubtitle = () => {
isSaving.value = true
const encodedSubtitle = encode(generateSrt())
useFetchWrapped<
req.gen.CourseSubtitleCreate & AuthedRequest,
BaseResponse<resp.gen.CourseSubtitleCreate>
>('App.Digital_VideoSubtitle.CreateFile', {
token: loginState.token!,
user_id: loginState.user.id,
sub_type: 1,
sub_content: encodedSubtitle,
task_id: props.course?.task_id,
})
.then((_) => {
modified.value = false
toast.add({
color: 'success',
title: '字幕已保存',
description: '修改后的字幕文件已保存',
})
})
.finally(() => {
isSaving.value = false
})
}
const exportVideo = async () => {
isExporting.value = true
const srtResponse = await (
await fetch(await fetchCourseSubtitleUrl(props.course))
).blob()
if (!srtResponse) {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'error',
icon: 'i-tabler-alert-triangle',
})
return
}
const srtBlob = new Blob([srtResponse], { type: 'text/plain' })
const srtUrl = URL.createObjectURL(srtBlob)
useVideoSubtitleEmbedding(props.course.video_url, srtUrl, {
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize,
textShadow:
subtitleStyleState.effect === 'shadow-xs'
? {
offsetX: 2,
offsetY: 2,
blur: 6,
color: 'rgba(0, 0, 0, 0.35)',
}
: {
offsetX: 0,
offsetY: 0,
blur: 0,
color: 'transparent',
},
strokeStyle: subtitleStyleState.effect === 'stroke' ? '#000 2px' : 'none',
bottomOffset: subtitleStyleState.bottomOffset,
})
.then((blobUrl) => {
const { download } = useDownload(blobUrl, 'combined_video.mp4')
download()
})
.finally(() => {
isExporting.value = false
})
}
onMounted(() => {
if (rawSrt.value) {
parseSrt(rawSrt.value)
}
})
defineExpose({
open() {
isDrawerActive.value = true
if (!rawSrt.value) loadSrt()
},
close() {
isDrawerActive.value = false
},
})
</script>
<template>
<div>
<USlideover
v-model:open="isDrawerActive"
:dismissible="!modified"
:ui="{
wrapper: 'max-w-lg',
body: 'flex flex-col flex-1 overflow-hidden',
}"
>
<template #content>
<UCard
class="flex flex-1 flex-col overflow-hidden"
:ui="{
body: 'overflow-auto flex-1',
}"
>
<template #header>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="tabler:x"
class="absolute end-5 top-5 z-10 flex sm:hidden"
square
padded
@click="isDrawerActive = false"
/>
<div class="flex flex-col">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
字幕编辑器
</h3>
<h3
class="text-xs font-semibold text-blue-500"
v-if="course.title"
>
{{ course.title }}
</h3>
</div>
</template>
<div
v-if="isLoading"
class="text-primary flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur
in="SourceGraphic"
result="y"
stdDeviation="1"
/>
<feColorMatrix
in="y"
result="z"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"
/>
<feBlend
in="SourceGraphic"
in2="z"
/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle
cx="5"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="5;8;5"
/>
</circle>
<circle
cx="19"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="19;16;19"
/>
</circle>
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"
/>
</g>
</svg>
</div>
<div
v-else
class="overshadow flex h-full flex-col gap-2 overflow-hidden overscroll-y-none"
>
<div class="relative aspect-video w-full flex-1">
<div
class="subtitle absolute inset-x-0 mx-auto w-fit font-sans font-bold"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-xs'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
{{ subtitles.find((sub) => sub.active)?.text }}
</div>
<video
controls
ref="videoElement"
class="rounded-xs"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
/>
</div>
<UAccordion
:items="[{ label: '字幕选项' }]"
color="gray"
size="lg"
>
<template #content>
<div
class="space-y-4 rounded-lg border p-4 pb-6 dark:border-neutral-700"
>
<div class="flex w-full flex-col justify-center">
<div
class="relative aspect-video w-full overflow-hidden rounded-md"
>
<img
class="h-full w-full rounded-md object-cover"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="subtitle absolute bottom-0 left-1/2 -translate-x-1/2 transform font-sans font-bold"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow-xs'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
字幕样式预览
</span>
</div>
<span class="text-2xs font-medium italic opacity-50 mt-1">
字幕预览仅供参考以实际渲染效果为准
</span>
</div>
<UForm
:schema="subtitleStyleSchema"
:state="subtitleStyleState"
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormField
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
{
label: '黑色',
value: '#000',
},
{
label: '白色',
value: '#fff',
},
]"
value-key="value"
v-model="subtitleStyleState.color"
/>
</UFormField>
<UFormField
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:items="[
{
label: '阴影',
value: 'shadow-xs',
},
{
label: '描边',
value: 'stroke',
},
]"
value-key="value"
v-model="subtitleStyleState.effect"
/>
</UFormField>
</div>
<UFormField
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<USlider
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormField>
<UFormField
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<USlider
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormField>
</UForm>
</div>
</template>
</UAccordion>
<ul
class="relative flex-1 space-y-0.5 overflow-y-auto scroll-smooth px-0.5 pb-[100%]"
>
<li
v-for="(subtitle, index) in subtitles"
:key="index"
:id="'subtitle-' + index"
>
<div :class="{ 'text-primary': subtitle.active }">
<span class="text-xs font-medium opacity-60">
{{ formatTimeToDayjs(subtitle.start).format('HH:mm:ss') }}
-
{{ formatTimeToDayjs(subtitle.end).format('HH:mm:ss') }}
<span class="opacity-50">
[{{
formatTimeToDayjs(subtitle.end).diff(
formatTimeToDayjs(subtitle.start),
'second'
)
}}s]
</span>
</span>
<UInput
v-model="subtitle.text"
class="w-full"
placeholder="请输入字幕内容"
:name="'subtitle-' + index"
:autofocus="false"
:color="subtitle.active ? 'primary' : undefined"
@click="onSubtitleInputClick(subtitle)"
@input="
() => {
if (!modified) modified = true
}
"
>
<template #trailing>
<UIcon
v-show="subtitle.active"
name="tabler:keyframe-align-vertical-filled"
/>
</template>
</UInput>
</div>
</li>
</ul>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<span
v-if="modified"
class="text-sm font-medium text-yellow-500"
>
已更改但未保存
</span>
<UButton
:loading="isExporting"
variant="soft"
icon="i-tabler-file-export"
@click="exportVideo"
>
导出视频
</UButton>
<UButton
:disabled="isExporting || !modified"
:loading="isSaving"
icon="i-tabler-device-floppy"
@click="saveNewSubtitle"
>
保存{{ isSaving ? '中' : '' }}
</UButton>
</div>
</template>
</UCard>
</template>
</USlideover>
</div>
</template>
<style scoped>
@reference '@/assets/css/main.css';
.overshadow {
@apply relative;
}
.overshadow:after {
content: '';
inset: 80% 0 0;
position: absolute;
@apply bg-linear-to-b pointer-events-none from-transparent to-white dark:to-neutral-950;
}
.subtitle.stroke {
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000;
}
.subtitle.shadow {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ export const useHistory = defineStore(
},
{
persist: {
storage: persistedState.localStorage,
storage: piniaPluginPersistedstate.localStorage(),
},
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ const navigateToPage = (path: string) => {
</div>
<div class="p-4">
<div class="grid grid-cols-1 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
v-for="page in adminPages"
:key="page.path"
@@ -62,14 +62,11 @@ const navigateToPage = (path: string) => {
@click="navigateToPage(page.path)"
>
<UCard
class="hover:shadow-lg transition-all duration-200 group-hover:scale-105"
:ui="{
ring: 'ring-1 ring-gray-200 dark:ring-gray-700 group-hover:ring-gray-300 dark:group-hover:ring-gray-600',
}"
class="transition-all duration-200 hover:shadow-lg group-hover:scale-105"
>
<div class="flex flex-col items-center text-center p-6">
<div class="flex flex-col items-center p-6 text-center">
<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="{
'bg-blue-100 dark:bg-blue-900/30': page.color === 'blue',
'bg-amber-100 dark:bg-amber-900/30': page.color === 'amber',
@@ -78,7 +75,7 @@ const navigateToPage = (path: string) => {
>
<UIcon
:name="page.icon"
class="w-8 h-8"
class="h-8 w-8"
:class="{
'text-blue-600 dark:text-blue-400': page.color === 'blue',
'text-amber-600 dark:text-amber-400':
@@ -90,24 +87,24 @@ const navigateToPage = (path: string) => {
</div>
<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 }}
</h3>
<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 }}
</p>
<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>
<UIcon
name="i-heroicons-arrow-right"
class="ml-1 w-4 h-4"
class="ml-1 h-4 w-4"
/>
</div>
</div>

View File

@@ -0,0 +1,856 @@
<script lang="ts" setup>
import type { TableColumn } from '#ui/types'
const route = useRoute()
const toast = useToast()
const dayjs = useDayjs()
const loginState = useLoginState()
const systemServices: ServiceType[] = [
{
tag: 'PPTToVideo',
name: '微课视频(PPT驱动)',
},
{
tag: 'TextToVideo',
name: '绿幕视频(文字驱动)',
},
{
tag: 'SparkChat15',
name: '讯飞星火大模型 1.5',
},
{
tag: 'SparkChat30',
name: '讯飞星火大模型 3.0',
},
{
tag: 'SparkChat35',
name: '讯飞星火大模型 3.5',
},
{
tag: 'TenImgToImg',
name: '腾讯混元 - 图生图',
},
{
tag: 'TenTextToImg',
name: '腾讯混元 - 文生图',
},
]
const columns: TableColumn<UserSchema>[] = [
{
accessorKey: 'id',
header: 'ID',
},
{
accessorKey: 'avatar',
header: '头像',
},
{
accessorKey: 'username',
header: '账户名/姓名',
},
{
accessorKey: 'company',
header: '单位',
},
{
accessorKey: 'mobile',
header: '手机号',
},
{
accessorKey: 'sex',
header: '性别',
},
{
accessorKey: 'auth_code',
header: '角色',
},
{
accessorKey: 'actions',
header: '操作',
},
]
const page = ref(1)
const pageCount = ref(15)
const state_filter = ref<'verified' | 'unverified'>('verified')
const is_verified = ref(true)
const viewingUser = ref<UserSchema | null>(null)
const isSlideOpen = computed({
get: () => !!viewingUser.value,
set: () => (viewingUser.value = null),
})
watch([is_verified, pageCount], () => (page.value = 1))
watch(
state_filter,
() => (is_verified.value = state_filter.value === 'verified')
)
const {
data: usersData,
refresh: refreshUsersData,
status: usersDataStatus,
} = useAsyncData(
'systemUsers',
() =>
useFetchWrapped<
req.user.UserList & AuthedRequest,
BaseResponse<PagedData<UserSchema>>
>('App.User_User.ListUser', {
token: loginState.token!,
user_id: loginState.user.id!,
page: page.value,
perpage: pageCount.value,
is_verify: is_verified.value,
}),
{
watch: [page, pageCount, is_verified],
transform: (res) => {
res.data.items.unshift(loginState.user)
return res
},
}
)
const isDigitalSelectorOpen = ref(false)
const dhPage = ref(1)
const dhPageCount = ref(10)
watch(dhPageCount, () => (dhPage.value = 1))
onMounted(() => {
if (route.query?.unverified) {
state_filter.value = 'unverified'
}
})
const {
data: digitalHumansData,
refresh: refreshDigitalHumansData,
status: digitalHumansDataStatus,
} = useAsyncData(
'currentUserDigitalHumans',
() =>
useFetchWrapped<
PagedDataRequest & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.User_UserDigital.GetList', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
page: dhPage.value,
perpage: dhPageCount.value,
}),
{
watch: [viewingUser, dhPage, dhPageCount],
}
)
const {
data: userBalances,
status: userBalancesStatus,
refresh: refreshUserBalances,
} = useAsyncData(
'userServiceBalances',
() =>
useFetchWrapped<
PagedDataRequest & AuthedRequest,
BaseResponse<PagedData<ServiceBalance>>
>('App.User_UserBalance.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: viewingUser.value?.id || 0,
page: 1,
perpage: 20,
}),
{
watch: [viewingUser],
}
)
const getBalanceByTag = (tag: ServiceTag): ServiceBalance | null => {
return (
userBalances?.value?.data.items.find((row) => row.request_type === tag) ||
null
)
}
const items = (row: UserSchema) => [
[
{
label: '服务和用量',
icon: 'tabler:server-cog',
onClick: () => openSlide(row),
disabled: row.auth_code === 0,
},
],
[
{
disabled: row.id === loginState.user.id,
label: row.auth_code !== 0 ? '停用账号' : '启用账号',
icon: row.auth_code !== 0 ? 'tabler:cancel' : 'tabler:shield-check',
onClick: () => setUserStatus(row.id, row.auth_code === 0),
},
],
]
const openSlide = (user: UserSchema) => {
viewingUser.value = user
}
const closeSlide = () => {
viewingUser.value = null
}
const onDigitalHumansSelected = (digitalHumans: DigitalHumanItem[]) => {
useFetchWrapped<
{
to_user_id: number
digital_human_array: number[]
} & AuthedRequest,
BaseResponse<{
total: number
success: number
failed: number
}>
>('App.User_UserDigital.CreateConnArr', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
digital_human_array: digitalHumans.map(
(row) => row.id || row.digital_human_id || 0
),
}).then((res) => {
if (res.ret === 200) {
toast.add({
title: '授权成功',
description: `成功授权 ${res.data.success} 个数字人${
res.data.failed ? `,失败 ${res.data.failed}` : ''
}`,
color: 'success',
icon: 'tabler:check',
})
refreshDigitalHumansData()
} else {
toast.add({
title: '授权失败',
description: res.msg || '未知错误',
color: 'error',
icon: 'tabler:alert-triangle',
})
}
})
}
const revokeDigitalHuman = (uid: number, digitalHumanId: number) => {
useFetchWrapped<
{
to_user_id: number
digital_human_id: number
} & AuthedRequest,
BaseResponse<{
code: number // 1: success, 0: failed
}>
>('App.User_UserDigital.DeleteConn', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: uid,
digital_human_id: digitalHumanId,
}).then((res) => {
if (res.ret === 200 && res.data.code === 1) {
toast.add({
title: '撤销成功',
description: '已撤销数字人授权',
color: 'success',
icon: 'tabler:check',
})
refreshDigitalHumansData()
} else {
toast.add({
title: '撤销失败',
description: res.msg || '未知错误',
color: 'error',
icon: 'tabler:alert-triangle',
})
}
})
}
const setUserStatus = (uid: number, is_verified: boolean) => {
useFetchWrapped<
{
to_user_id: number
is_verify: boolean
} & AuthedRequest,
BaseResponse<{
status: number // 1: success, 0: failed
}>
>('App.User_User.SetUserVerify', {
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: uid,
is_verify: is_verified,
}).then((res) => {
if (res.ret === 200 && res.data.status === 1) {
toast.add({
title: '操作成功',
description: `${is_verified ? '启用' : '停用'}账号`,
color: 'success',
icon: is_verified ? 'tabler:shield-check' : 'tabler:cancel',
})
refreshUsersData()
} else {
toast.add({
title: '操作失败',
description: res.msg || '未知错误',
color: 'error',
icon: 'tabler:alert-triangle',
})
}
})
}
const userBalanceEditing = ref(false)
const userBalanceState = reactive({
expire_time: dayjs().add(1, 'month').unix() * 1000,
remain_count: 1800,
request_type: '',
})
const isActivateBalance = ref(false)
const isBalanceEditModalOpen = computed({
get: () => !!viewingUser && userBalanceEditing.value,
set: (val) => {
userBalanceEditing.value = val
if (!val) {
userBalanceState.expire_time = dayjs().add(1, 'month').unix() * 1000
userBalanceState.remain_count = 1800
}
},
})
const udpateBalance = (tag: ServiceTag, isActivate: boolean = false) => {
useFetchWrapped<
{
to_user_id: number
request_type: ServiceTag
expire_time: number
remain_count: number
} & AuthedRequest,
BaseResponse<{
code: number // 1: success, 0: failed
data_id?: number
}>
>(
isActivate ? 'App.User_UserBalance.Insert' : 'App.User_UserBalance.Update',
{
token: loginState.token!,
user_id: loginState.user.id!,
to_user_id: viewingUser.value?.id || 0,
request_type: tag,
expire_time: userBalanceState.expire_time / 1000,
remain_count: userBalanceState.remain_count,
}
)
.then((res) => {
if (res.ret === 200 && (res.data.code === 1 || !!res.data.data_id)) {
toast.add({
title: '操作成功',
description: `${isActivate ? '开通' : '更新'}服务`,
color: 'success',
icon: 'tabler:check',
})
} else {
toast.add({
title: '操作失败',
description: res.msg || '未知错误',
color: 'error',
icon: 'tabler:alert-triangle',
})
}
})
.catch((err) => {
toast.add({
title: '操作失败',
description: err.message || '未知错误',
color: 'error',
icon: 'tabler:alert-triangle',
})
})
.finally(() => {
refreshUserBalances()
isBalanceEditModalOpen.value = false
})
}
</script>
<template>
<LoginNeededContent need-admin>
<div class="p-4">
<BubbleTitle
bubble-color="amber-500"
subtitle="User Management"
title="用户管理"
>
<template #action>
<UButton
color="warning"
icon="tabler:reload"
variant="soft"
@click="() => refreshUsersData()"
>
刷新
</UButton>
</template>
</BubbleTitle>
<GradientDivider
line-gradient-from="amber"
line-gradient-to="amber"
/>
<div>
<div class="flex w-full items-center justify-between py-3">
<div class="flex items-center gap-1.5">
<span class="leading-0 text-sm">每页显示:</span>
<USelect
v-model="pageCount"
:items="[5, 10, 15, 20]"
class="me-2 w-20"
size="xs"
/>
</div>
<div class="flex items-center gap-1.5">
<USelectMenu
v-model="state_filter"
:items="[
{
label: '正常账号',
value: 'verified',
icon: 'tabler:user-check',
},
{
label: '停用账号',
value: 'unverified',
icon: 'tabler:user-cancel',
},
]"
value-key="value"
:ui-menu="{
width: 'w-fit',
option: { size: 'text-xs', icon: { base: 'w-4 h-4' } },
}"
size="xs"
/>
</div>
</div>
<UTable
:columns="columns"
:loading="usersDataStatus === 'pending'"
:progress="{ color: 'amber', animation: 'carousel' }"
:data="usersData?.data.items"
class="rounded-md border dark:border-neutral-800"
>
<template #username-cell="{ row }">
<span
:class="{
'font-semibold text-amber-500':
row.original.id === loginState.user.id,
}"
>
{{ row.original.username }}
<span class="text-xs">
{{ row.original.id === loginState.user.id ? ' (本账号)' : '' }}
</span>
</span>
</template>
<template #avatar-cell="{ row }">
<UAvatar
:alt="row.original.username.toUpperCase()"
:src="row.original.avatar"
size="sm"
/>
</template>
<template #sex-cell="{ row }">
{{
row.original.sex === 0 ? '' : row.original.sex === 1 ? '男' : '女'
}}
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="items(row.original)">
<UButton
color="neutral"
icon="tabler:dots"
variant="ghost"
/>
</UDropdownMenu>
</template>
</UTable>
<div class="flex justify-end py-3.5">
<UPagination
v-if="(usersData?.data.total || -1) > 0"
v-model:page="page"
:page-count="pageCount"
:total="usersData?.data.total || 0"
/>
</div>
</div>
</div>
<USlideover
v-model:open="isSlideOpen"
:ui="{ content: 'w-screen max-w-3xl' }"
>
<template #content>
<UCard
:ui="{
body: 'flex-1 overflow-y-auto',
}"
class="flex flex-col overflow-hidden"
>
<template #header>
<UButton
class="absolute end-5 top-5 z-10 flex"
color="neutral"
variant="ghost"
icon="tabler:x"
padded
size="sm"
square
@click="closeSlide"
/>
服务和用量管理
<p class="text-primary text-sm font-medium">
{{ viewingUser?.username }} (UID:{{ viewingUser?.id }})
</p>
</template>
<div class="">
<USeparator
label="服务用量管理"
class="mb-4"
:ui="{ label: 'text-primary-500 dark:text-primary-400' }"
/>
<div class="rounded-md border dark:border-neutral-700">
<UTable
:columns="[
{ accessorKey: 'service', header: '服务' },
{ accessorKey: 'status', header: '状态' },
{ accessorKey: 'create_time', header: '开通时间' },
{ accessorKey: 'expire_time', header: '过期时间' },
{ accessorKey: 'remain_count', header: '余量(秒)' },
{ accessorKey: 'actions' },
]"
:loading="userBalancesStatus === 'pending'"
:data="[
...systemServices,
// 如果 userBalances?.data.items 具有 systemServices 中没有的服务,则添加到列表中
...(userBalances?.data.items
.filter(
(row) =>
!systemServices.find((s) => s.tag === row.request_type)
)
.map((row) => ({
tag: row.request_type,
name: row.request_type,
})) || []),
]"
>
<template #service-cell="{ row }">
{{
systemServices.find((s) => s.tag === row.original.tag)
?.name || row.original.tag
}}
</template>
<template #status-cell="{ row }">
<UBadge
v-if="!getBalanceByTag(row.original.tag)"
color="neutral"
variant="soft"
size="xs"
>
未开通
</UBadge>
<UBadge
v-else-if="
getBalanceByTag(row.original.tag)!.expire_time <
dayjs().unix()
"
color="error"
variant="subtle"
size="xs"
>
已过期
</UBadge>
<UBadge
v-else
color="success"
variant="subtle"
size="xs"
>
生效中
</UBadge>
</template>
<template #create_time-cell="{ row }">
<span class="text-xs">
{{
!!getBalanceByTag(row.original.tag)
? dayjs(
getBalanceByTag(row.original.tag)!.create_time *
1000
).format('YYYY-MM-DD HH:mm:ss')
: '未开通'
}}
</span>
</template>
<template #expire_time-cell="{ row }">
<span class="text-xs">
{{
!!getBalanceByTag(row.original.tag)
? dayjs(
getBalanceByTag(row.original.tag)!.expire_time *
1000
).format('YYYY-MM-DD HH:mm:ss')
: '未开通'
}}
</span>
</template>
<template #remain_count-cell="{ row }">
<span class="text-sm">
{{ getBalanceByTag(row.original.tag)?.remain_count || 0 }}
</span>
</template>
<template #actions-cell="{ row }">
<UButton
v-if="!getBalanceByTag(row.original.tag)"
color="success"
icon="tabler:clock-check"
size="xs"
variant="soft"
@click="
() => {
isActivateBalance = true
userBalanceEditing = true
userBalanceState.request_type = row.original.tag
}
"
>
开通
</UButton>
<UButton
v-else-if="
getBalanceByTag(row.original.tag)!.expire_time <
dayjs().unix()
"
color="info"
icon="tabler:clock-plus"
size="xs"
variant="soft"
@click="
() => {
isActivateBalance = false
userBalanceEditing = true
userBalanceState.request_type = row.original.tag
}
"
>
延续
</UButton>
<UButton
v-else
color="info"
icon="tabler:rotate-clockwise"
size="xs"
variant="soft"
@click="
() => {
isActivateBalance = false
userBalanceEditing = true
userBalanceState.request_type = row.original.tag
userBalanceState.expire_time =
getBalanceByTag(row.original.tag)!.expire_time * 1000
userBalanceState.remain_count = getBalanceByTag(
row.original.tag
)!.remain_count
}
"
>
更新
</UButton>
</template>
</UTable>
<UModal
v-model:open="isBalanceEditModalOpen"
:ui="{ content: 'w-xl' }"
>
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-1">
<h1 class="text-sm font-medium">
{{ isActivateBalance ? '开通' : '续期' }}
{{
systemServices.find(
(s) => s.tag === userBalanceState.request_type
)?.name || userBalanceState.request_type
}}
服务
</h1>
<p
class="inline-flex items-center gap-1 text-xs text-indigo-500"
>
<UIcon
name="tabler:user-circle"
class="text-base"
/>
{{ viewingUser!.username }}
</p>
</div>
</template>
<div class="flex justify-between gap-4">
<UFormField
label="到期时间"
class="flex-1"
>
<UPopover :popper="{ placement: 'bottom-start' }">
<UButton
block
variant="soft"
icon="i-heroicons-calendar-days-20-solid"
:label="
dayjs(userBalanceState.expire_time).format(
'YYYY-MM-DD HH:mm'
)
"
/>
<template #panel="{ close }">
<DatePicker
v-model="userBalanceState.expire_time"
locale="cn"
mode="dateTime"
title-position="left"
is-required
is24hr
@close="close"
/>
</template>
</UPopover>
</UFormField>
<UFormField
label="服务时长(秒)"
class="flex-1"
>
<UInput v-model="userBalanceState.remain_count" />
</UFormField>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2">
<UButton
color="neutral"
variant="ghost"
size="sm"
@click="isBalanceEditModalOpen = false"
>
取消
</UButton>
<UButton
color="primary"
icon="tabler:check"
size="sm"
@click="
udpateBalance(
userBalanceState.request_type as ServiceTag,
isActivateBalance
)
"
>
{{ isActivateBalance ? '开通' : '续期' }}
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</div>
<USeparator
label="数字人权限管理"
class="my-4"
:ui="{ label: 'text-primary-500 dark:text-primary-400' }"
/>
<div class="rounded-md border dark:border-neutral-700">
<UTable
:columns="[
{ accessorKey: 'name', header: '名称' },
{ accessorKey: 'digital_human_id', header: '本地ID' },
{ accessorKey: 'model_id', header: '上游ID' },
{ accessorKey: 'actions' },
]"
:loading="digitalHumansDataStatus === 'pending'"
:data="digitalHumansData?.data.items"
>
<template #actions-cell="{ row }">
<UButton
color="neutral"
variant="ghost"
icon="tabler:cancel"
size="xs"
@click="
revokeDigitalHuman(
viewingUser?.id || 0,
row.original.digital_human_id || 0
)
"
>
撤销授权
</UButton>
</template>
</UTable>
</div>
<div class="flex justify-between py-3.5">
<UButton
icon="tabler:plus"
size="xs"
@click="isDigitalSelectorOpen = true"
>
新增授权
</UButton>
<UPagination
v-if="(digitalHumansData?.data.total || -1) > 0"
v-model:page="dhPage"
:page-count="dhPageCount"
:total="digitalHumansData?.data.total || 0"
/>
</div>
</div>
</UCard>
<ModalDigitalHumanSelect
:disabled-digital-human-ids="
digitalHumansData?.data.items.map((d) => d.model_id)
"
:is-open="isDigitalSelectorOpen"
default-tab="system"
multiple
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) =>
onDigitalHumansSelected(digitalHumans as DigitalHumanItem[])
"
/>
</template>
</USlideover>
</LoginNeededContent>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,555 +0,0 @@
<script lang="ts" setup>
import { object, string } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
const toast = useToast()
const loginState = useLoginState()
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// 表单状态
const formState = reactive({
dh_name: '',
organization: '',
})
// 文件上传状态
const videoFile = ref<File | null>(null)
const authVideoFile = ref<File | null>(null)
const isSubmitting = ref(false)
// 上传进度状态
const uploadProgress = reactive({
step: 0,
total: 3,
message: '',
})
// 表单验证
const schema = object({
dh_name: string()
.required('请输入数字人名称')
.max(50, '数字人名称不能超过50个字符'),
organization: string()
.required('请输入单位名称')
.max(100, '单位名称不能超过100个字符'),
})
// 更新上传进度
const updateProgress = (step: number, message: string) => {
uploadProgress.step = step
uploadProgress.message = message
}
// 处理数字人视频上传
const handleVideoUpload = (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!['video/mp4', 'video/mov'].includes(file.type)) {
toast.add({
title: '文件格式错误',
description: '仅支持MP4和MOV格式的视频文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小 (1GB)
if (file.size > 1024 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过1GB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
videoFile.value = file
toast.add({
title: '文件上传成功',
description: '数字人视频已选择',
color: 'green',
icon: 'i-tabler-check',
})
}
// 处理授权视频上传
const handleAuthVideoUpload = (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!['video/mp4', 'video/mov'].includes(file.type)) {
toast.add({
title: '文件格式错误',
description: '仅支持MP4和MOV格式的视频文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小
if (file.size > 1024 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过1GB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
authVideoFile.value = file
toast.add({
title: '文件上传成功',
description: '授权视频已选择',
color: 'green',
icon: 'i-tabler-check',
})
}
// 提交表单
const onSubmit = async (event: FormSubmitEvent<typeof formState>) => {
// 验证文件是否已上传
if (!videoFile.value) {
toast.add({
title: '请上传数字人视频素材',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
if (!authVideoFile.value) {
toast.add({
title: '请上传形象授权视频',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
if (isSubmitting.value) return
try {
isSubmitting.value = true
updateProgress(0, '开始创建数字人...')
// 上传数字人视频素材
updateProgress(1, '上传数字人视频素材...')
const videoUrl = await useFileGo(videoFile.value, 'material')
// 上传形象授权视频
updateProgress(2, '上传形象授权视频...')
const authVideoUrl = await useFileGo(authVideoFile.value, 'material')
// 创建数字人定制记录
updateProgress(3, '创建数字人定制记录...')
const response = await useFetchWrapped<
{
user_id: number
dh_name: string
organization: string
video_url: string
auth_video_url: string
} & AuthedRequest,
BaseResponse<{ train_id: number }>
>('App.Digital_Train.Create', {
token: loginState.token!,
user_id: loginState.user.id,
dh_name: event.data.dh_name,
organization: event.data.organization,
video_url: videoUrl,
auth_video_url: authVideoUrl,
})
if (response.ret === 200 && response.data.train_id) {
toast.add({
title: '数字人定制提交成功',
description: '您的数字人定制请求已提交,请等待管理员处理',
color: 'green',
icon: 'i-tabler-check',
})
// 重置表单
formState.dh_name = ''
formState.organization = ''
videoFile.value = null
authVideoFile.value = null
uploadProgress.step = 0
uploadProgress.message = ''
// 关闭弹窗
isOpen.value = false
} else {
throw new Error(response.msg || '创建失败')
}
} catch (error) {
console.error('数字人定制失败:', error)
const errorMessage =
error instanceof Error ? error.message : '数字人定制失败,请重试'
toast.add({
title: '提交失败',
description: errorMessage,
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isSubmitting.value = false
uploadProgress.step = 0
uploadProgress.message = ''
}
}
// 重置表单
const resetForm = () => {
formState.dh_name = ''
formState.organization = ''
videoFile.value = null
authVideoFile.value = null
uploadProgress.step = 0
uploadProgress.message = ''
}
// 监听弹窗关闭事件,重置表单
watch(isOpen, (newValue) => {
if (!newValue) {
resetForm()
}
})
// 显示授权文案弹窗
const showAuthModal = ref(false)
</script>
<template>
<UModal
v-model="isOpen"
:ui="{ width: 'sm:max-w-6xl' }"
>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
数字人定制
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="isOpen = false"
/>
</div>
</template>
<div class="grid grid-cols-7 gap-6">
<!-- 左侧表单 -->
<div class="col-span-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-800">
<UForm
:schema="schema"
:state="formState"
class="space-y-4"
@submit="onSubmit"
>
<!-- 数字人视频素材 -->
<UFormGroup
label="数字人视频素材"
required
>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-video-camera"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{ videoFile ? videoFile.name : '点击或拖拽上传视频' }}
</span>
</div>
<p class="text-xs text-gray-500 mt-1">
小于 1GB mov/mp4 格式比例 9:16帧率 25FPS分辨率
1080P时长 3-6 分钟
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<!-- 数字人名称 -->
<UFormGroup
label="数字人名称"
name="dh_name"
required
>
<UInput
v-model="formState.dh_name"
placeholder="请输入数字人名称"
/>
</UFormGroup>
<!-- 单位名称 -->
<UFormGroup
label="单位名称"
name="organization"
required
>
<UInput
v-model="formState.organization"
placeholder="请输入单位名称"
/>
</UFormGroup>
<!-- 形象授权视频 -->
<UFormGroup
label="形象授权视频"
required
>
<template #description>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">
请确保本人进行形象授权视频录制否则脸部比对将不通过导致制作失败
</span>
<UButton
variant="link"
size="xs"
icon="i-heroicons-document-text"
@click="showAuthModal = true"
>
授权文案
</UButton>
</div>
</template>
<UniFileDnD
accept="video/mp4,video/mov"
class="h-36"
@change="handleAuthVideoUpload"
>
<template #default>
<div class="text-center">
<UIcon
name="i-heroicons-shield-check"
class="mx-auto h-12 w-12 text-gray-400"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
authVideoFile
? authVideoFile.name
: '点击或拖拽上传授权视频'
}}
</span>
</div>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<!-- 提交按钮 -->
<UButton
type="submit"
class="w-full"
:loading="isSubmitting"
:disabled="isSubmitting"
color="primary"
>
{{ isSubmitting ? '提交中...' : '确认提交' }}
</UButton>
<!-- 上传进度 -->
<div
v-if="isSubmitting"
class="mt-4 space-y-2"
>
<div class="flex justify-between text-sm">
<span>{{ uploadProgress.message }}</span>
<span>
{{ uploadProgress.step }}/{{ uploadProgress.total }}
</span>
</div>
<UProgress
:value="(uploadProgress.step / uploadProgress.total) * 100"
color="primary"
/>
</div>
</UForm>
</div>
<!-- 右侧教程和提示 -->
<div class="col-span-4 p-4 rounded-lg border dark:border-gray-700">
<div class="flex flex-col h-full gap-6">
<!-- 教程视频 -->
<div class="flex-1">
<h3
class="text-lg font-semibold mb-3 text-gray-800 dark:text-white flex items-center gap-2"
>
<UIcon
name="i-heroicons-video-camera"
class="h-5 w-5"
/>
视频录制教程
</h3>
<div
class="w-full aspect-video border rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
>
<UIcon
name="i-heroicons-video-camera"
class="h-12 w-12 text-gray-400"
/>
</div>
</div>
<!-- 联系方式 -->
<div
class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-700"
>
<div class="flex items-center gap-3">
<div class="bg-blue-100 dark:bg-blue-900 p-2 rounded-lg">
<UIcon
name="i-heroicons-chat-bubble-left-right"
class="h-5 w-5 text-blue-600 dark:text-blue-400"
/>
</div>
<div>
<p class="text-sm font-medium text-gray-800 dark:text-white">
需要帮助
</p>
<p class="text-sm text-gray-600 dark:text-gray-300">
客服微信
<span class="font-mono text-blue-600 dark:text-blue-400">
xxxxxx
</span>
</p>
</div>
</div>
</div>
<!-- 录制指南 -->
<div
class="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-700"
>
<div class="flex items-start gap-3">
<div
class="bg-amber-100 dark:bg-amber-900 p-2 rounded-lg mt-0.5"
>
<UIcon
name="i-heroicons-light-bulb"
class="h-5 w-5 text-amber-600 dark:text-amber-400"
/>
</div>
<div class="flex-1">
<h4
class="text-sm font-semibold text-gray-800 dark:text-white mb-3"
>
录制注意事项
</h4>
<div class="space-y-2">
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-sun"
class="h-4 w-4 text-amber-500 mt-0.5 flex-shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
确保光线充足避免背光
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-speaker-wave"
class="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
选择安静环境减少噪音干扰
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-viewfinder-circle"
class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
人脸占画面比例控制在 1/4 以内
</span>
</div>
<div class="flex items-center gap-2">
<UIcon
name="i-heroicons-face-smile"
class="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-300">
保持自然表情使用恰当手势
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</UCard>
<!-- 授权文案弹窗 -->
<UModal v-model="showAuthModal">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
授权视频文案
</h3>
<UButton
color="gray"
variant="ghost"
icon="i-heroicons-x-mark-20-solid"
class="-my-1"
@click="showAuthModal = false"
/>
</div>
</template>
<div class="p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
请确保您是视频中人物的合法授权人在授权视频中朗读以下文案
</p>
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4">
<p class="text-sm leading-relaxed text-gray-800 dark:text-gray-200">
我是在"AI智慧职教平台"定制上传视频的模特本人我承诺已经按照平台规则进行合法授权特此承诺
</p>
</div>
</div>
</UCard>
</UModal>
</UModal>
</template>
<style scoped></style>

View File

@@ -1,317 +0,0 @@
<script lang="ts" setup>
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const props = defineProps({
isOpen: {
type: Boolean,
required: false,
},
multiple: {
type: Boolean,
default: false,
},
disabledDigitalHumanIds: {
type: Array,
default: () => [],
},
defaultTab: {
type: String as PropType<'user' | 'system'>,
default: 'user',
},
})
const emit = defineEmits({
close: () => true,
select: (digitalHumans: DigitalHumanItem | DigitalHumanItem[]) =>
digitalHumans,
})
const loginState = useLoginState()
const modal = useModal()
const toast = useToast()
const page = ref(1)
const sourceTypeList = [
{ label: 'xsh_wm', value: 1, color: 'blue' }, // 万木(腾讯)
{ label: 'xsh_zy', value: 2, color: 'green' }, // XSH 自有
{ label: 'xsh_fh', value: 3, color: 'purple' }, // 硅基(泛化数字人)
{ label: 'xsh_bb', value: 4, color: 'indigo' }, // 百度小冰
]
// const sourceType = ref(sourceTypeList[0])
const selectedDigitalHumans = ref<DigitalHumanItem[]>([])
const handleSelectClick = (item: DigitalHumanItem) => {
// 如果点击的项目已经在已选列表中,则移除;否则添加
if (selectedDigitalHumans.value.includes(item)) {
selectedDigitalHumans.value = selectedDigitalHumans.value.filter(
(d) => d !== item
)
} else {
selectedDigitalHumans.value = props.multiple
? [...selectedDigitalHumans.value, item]
: [item]
}
}
const handleClose = () => {
selectedDigitalHumans.value = []
if (props.isOpen) {
emit('close')
} else {
modal.close()
}
}
const handleSubmit = () => {
if (selectedDigitalHumans.value.length === 0) {
toast.add({
title: '请选择数字人',
description: '请至少选择一个数字人',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
}
emit(
'select',
props.multiple
? selectedDigitalHumans.value
: selectedDigitalHumans.value[0]
)
handleClose()
setTimeout(() => {
page.value = 1
}, 300)
}
const tabItems = [
{
key: 'user',
label: '我的数字人',
icon: 'i-tabler-user',
},
]
const tabIndex = ref(0)
watch(tabIndex, () => {
page.value = 1
})
const { data: userDigitalList } = useAsyncData(
'user-digital-human',
() =>
useFetchWrapped<
req.gen.DigitalHumanList & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.User_UserDigital.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 15,
// source_type: sourceType.value.value,
}),
{
watch: [page],
}
)
const { data: systemDigitalList } = useAsyncData(
'system-digital-human',
() =>
useFetchWrapped<
req.gen.DigitalHumanList & AuthedRequest,
BaseResponse<PagedData<DigitalHumanItem>>
>('App.Digital_Human.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: page.value,
perpage: 15,
// source_type: sourceType.value.value,
}),
{
watch: [page],
}
)
onMounted(() => {
if (loginState.user.auth_code === 2) {
tabItems.push({
key: 'system',
label: '系统数字人',
icon: 'i-tabler-user-star',
})
nextTick(() => {
tabIndex.value = tabItems.findIndex((i) => i.key === props.defaultTab)
console.log('tabIndex', tabIndex.value)
})
}
})
</script>
<template>
<UModal
:model-value="isOpen"
:ui="{ width: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
数字人选择器
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<UTabs
v-model="tabIndex"
:items="tabItems"
>
<template #item="{ item }">
<div class="w-full grid grid-cols-3 sm:grid-cols-5 gap-4">
<div
v-for="(d, i) in item.key === 'user'
? userDigitalList?.data.items
: systemDigitalList?.data.items"
:key="`${item.key === 'user' ? 'user' : 'system'}-digital-${
d.model_id
}`"
:class="{
'border-primary shadow-md': selectedDigitalHumans.includes(d),
'border-neutral-200 dark:border-neutral-700':
!selectedDigitalHumans.includes(d),
}"
class="relative flex flex-col justify-center items-center gap-2 overflow-hidden w-full bg-white dark:bg-neutral-800 rounded-md border dark:border-2 cursor-pointer transition-all duration-150 select-none"
@click="
!disabledDigitalHumanIds.includes(d.model_id)
? handleSelectClick(d)
: void 0
"
>
<div
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute inset-0 bg-neutral-400 dark:bg-neutral-700 bg-opacity-50 dark:bg-opacity-50 cursor-not-allowed z-10"
></div>
<div
:class="{ 'bg-primary-50': selectedDigitalHumans.includes(d) }"
class="relative bg-neutral-100 dark:bg-neutral-800 border-b dark:border-neutral-700 w-full aspect-square object-cover overflow-hidden transition-all duration-150"
>
<NuxtImg
:src="d.avatar"
class="-translate-y-4"
/>
<UIcon
v-if="selectedDigitalHumans.includes(d)"
class="absolute top-1 right-1 text-lg text-primary"
name="i-tabler-check"
/>
<UIcon
v-if="disabledDigitalHumanIds.includes(d.model_id)"
class="absolute top-1 right-1 text-lg text-red-500"
name="tabler:user-off"
/>
<template
v-for="(t, i) in sourceTypeList"
:key="i"
>
<UBadge
v-if="t.value === d.type"
class="absolute bottom-1 right-1"
size="xs"
variant="subtle"
:color="t.color"
:label="t.label"
/>
</template>
</div>
<div class="w-full flex flex-col gap-1 px-2 pb-2">
<div class="flex justify-between items-center">
<span
class="text-sm text-neutral-800 dark:text-neutral-300 font-medium line-clamp-1"
>
{{ d.name }}
</span>
<span
class="text-xs text-neutral-300 dark:text-neutral-500 font-medium"
>
ID:{{ d.digital_human_id || d.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-between items-end">
<div class="flex items-center gap-2">
<!-- <span class="text-sm text-neutral-800 dark:text-neutral-300 font-medium">
选择来源:
</span>
<USelectMenu
v-model="sourceType"
:options="sourceTypeList.map(i => ({ label: i.label, value: i.value }))"
@change="page = 1"
/> -->
</div>
<UPagination
v-if="
(item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0) > 0
"
v-model="page"
:page-count="15"
:total="
item.key === 'user'
? userDigitalList?.data.total || 0
: systemDigitalList?.data.total || 0
"
class="pt-4"
/>
</div>
</template>
</UTabs>
<template #footer>
<div class="flex justify-between items-center">
<div>
<p class="text-xs font-medium opacity-50 select-none">
如果没有出现您的数字人,请联系管理员开通
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="gray"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped></style>

View File

@@ -1,187 +0,0 @@
<script lang="ts" setup>
const props = defineProps({
isOpen: {
type: Boolean,
required: false,
},
})
const emit = defineEmits({
close: () => true,
select: (titles: TitlesTemplate) => titles,
})
const toast = useToast()
const modal = useModal()
const loginState = useLoginState()
const pagination = reactive({
page: 1,
pageSize: 15,
})
const selectedTitle = ref<TitlesTemplate | null>(null)
const {
data: userTitlesTemplate,
status: userTitlesTemplateStatus,
refresh: refreshUserTitlesTemplate,
} = useAsyncData(
'userTitlesTemplate',
() =>
useFetchWrapped<
PagedDataRequest & AuthedRequest & { process_status: 0 | 1 },
BaseResponse<PagedData<TitlesTemplate>>
>('App.User_UserTitles.GetList', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: loginState.user.id,
page: pagination.page,
perpage: pagination.pageSize,
process_status: 1,
}),
{
watch: [pagination],
}
)
const handleClose = () => {
if (props.isOpen) {
emit('close')
} else {
modal.close()
}
}
const handleSubmit = () => {
if (!selectedTitle.value) {
toast.add({
title: '请选择片头',
description: '请选择一个片头',
color: 'red',
icon: 'i-tabler-circle-x',
})
return
}
emit('select', selectedTitle.value)
handleClose()
}
</script>
<template>
<UModal
:model-value="isOpen"
:ui="{ width: 'w-full sm:max-w-3xl' }"
@close="handleClose"
>
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
视频片头选择器
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="handleClose"
/>
</div>
</template>
<div>
<div class="w-full grid grid-cols-2 sm:grid-cols-3 gap-4">
<div
v-for="(titles, i) in userTitlesTemplate?.data.items"
:key="`user-titles-${titles.id}`"
:class="{
'border-primary shadow-md': selectedTitle?.id === titles.id,
'border-neutral-200 dark:border-neutral-700':
selectedTitle?.id !== titles.id,
}"
class="relative flex flex-col justify-center items-center gap-2 overflow-hidden w-full bg-white dark:bg-neutral-800 rounded-md border dark:border-2 cursor-pointer transition-all duration-150 select-none"
@click="selectedTitle = titles"
>
<div
:class="{
'bg-primary-50': selectedTitle?.id === titles.id,
}"
class="relative bg-neutral-100 dark:bg-neutral-800 border-b dark:border-neutral-700 w-full aspect-video object-cover overflow-hidden transition-all duration-150"
>
<NuxtImg :src="titles.opening_url" />
<UIcon
v-if="selectedTitle?.id === titles.id"
class="absolute top-1 right-1 text-lg text-primary"
name="i-tabler-check"
/>
</div>
<div class="w-full flex flex-col gap-1 px-2 pb-2">
<div class="flex justify-between items-center">
<span
class="text-sm text-neutral-800 dark:text-neutral-300 font-medium line-clamp-1"
>
{{ titles.title }}
</span>
<span
class="text-xs text-neutral-300 dark:text-neutral-500 font-medium"
>
ID:{{ titles.id }}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<UPagination
v-if="(userTitlesTemplate?.data.total || 0) > 0"
v-model="pagination.page"
:page-count="pagination.pageSize"
:total="userTitlesTemplate?.data.total || 0"
class="pt-4"
/>
</div>
</div>
<template #footer>
<div class="flex justify-between items-center">
<div>
<p class="text-xs font-medium opacity-50 select-none">
如果此处没有您的片头请在
<a
class="text-primary"
href="/generation/materials"
target="_blank"
>
片头模版库
</a>
页面确认已经制作完毕
</p>
</div>
<div class="flex items-center gap-4">
<UButton
color="gray"
label="取消"
variant="ghost"
@click="handleClose"
/>
<UButton
color="primary"
label="选择"
variant="solid"
@click="handleSubmit"
/>
</div>
</div>
</template>
</UCard>
</UModal>
</template>
<style scoped></style>

View File

@@ -1,361 +0,0 @@
<script lang="ts" setup>
import FileDnD from '~/components/uni/FileDnD/index.vue'
import { type InferType, number, object, string } from 'yup'
import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success'])
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
const creationForm = ref<HTMLFormElement>()
const creationPending = ref(false)
const isDigitalSelectorOpen = ref(false)
const isTitlesSelectorOpen = ref(false)
const createCourseSchema = object({
task_title: string()
.trim()
.min(4, '标题必须大于4个字符')
.max(20, '标题不能超过20个字符')
.required('请输入微课标题'),
digital_human_id: number().not([0], '请选择数字人'),
opening_url: string().url().notRequired().default(''),
ending_url: string().url().notRequired().default(''),
gen_server: string().required(),
speed: number().default(1.0).min(0.5).max(1.5).required(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
task_title: undefined,
digital_human_id: 0,
opening_url: '',
ending_url: '',
gen_server: 'main',
speed: 1.0,
})
const selected_file = ref<File[] | null>(null)
const selected_digital_human = ref<DigitalHumanItem | null>(null)
const selected_titles = ref<TitlesTemplate | null>(null)
watchEffect(() => {
if (selected_digital_human.value) {
// 2025.03.31 使用内部数字人 ID
createCourseState.digital_human_id =
selected_digital_human.value.digital_human_id ??
selected_digital_human.value.id ??
0
}
if (selected_titles.value) {
createCourseState.opening_url = selected_titles.value.opening_file
createCourseState.ending_url = selected_titles.value.ending_file
}
})
const onCreateCourseSubmit = async (
event: FormSubmitEvent<CreateCourseSchema>
) => {
if (!selected_file.value) {
toast.add({
title: '未选择文件',
description: '请先选择 PPTX 文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
creationPending.value = true
// upload PPTX file
useFileGo(selected_file.value[0], 'ppt').then((url) => {
useFetchWrapped<
req.gen.CourseGenCreate & AuthedRequest,
BaseResponse<resp.gen.CourseGenCreate>
>('App.Digital_Convert.Create', {
token: loginState.token!,
user_id: loginState.user.id,
task_title: event.data.task_title,
gen_server: event.data.gen_server as 'main' | 'standby1',
speed: event.data.speed,
ppt_url: url,
digital_human_id: event.data.digital_human_id,
custom_video: '[]',
opening_url: event.data.opening_url || '',
ending_url: event.data.opening_url || '',
})
.then((res) => {
if (res.data.record_status === 1) {
toast.add({
title: '创建成功',
description: '已加入生成队列',
color: 'green',
icon: 'i-tabler-check',
})
emit('success')
slide.close()
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
})
.catch((e) => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
})
}
</script>
<template>
<USlideover prevent-close>
<UCard
:ui="{
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建微课视频
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="slide.close()"
/>
</div>
</template>
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup
label="微课标题"
name="task_title"
required
>
<UInput
v-model="createCourseState.task_title"
placeholder="请输入微课标题"
/>
</UFormGroup>
</div>
<div class="grid grid-cols-2 gap-2">
<UFormGroup
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isDigitalSelectorOpen = true"
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</UFormGroup>
<UFormGroup
label="视频片头片尾"
name="opening"
>
<div
:class="{ 'shadow-inner': !!selected_titles }"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isTitlesSelectorOpen = true"
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
>
<UIcon
v-if="!selected_titles"
class="text-2xl opacity-50"
name="i-tabler-brackets-contain"
/>
<NuxtImg
v-else
:src="selected_titles?.opening_url"
/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span :class="!!selected_titles ? 'text-neutral-600' : ''">
{{ selected_titles?.title || '点击选择片头' }}
</span>
<span
v-if="selected_titles?.description"
class="text-2xs"
>
{{ selected_titles?.description }}
</span>
</div>
</div>
</UFormGroup>
<!-- <UFormGroup label="视频片头片尾" name="opening">
<div
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center">
<UIcon class="text-2xl opacity-50" name="i-tabler-brackets-contain"/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span>点击选择</span>
</div>
</div>
</UFormGroup> -->
</div>
<UFormGroup
label="PPT 文件"
required
>
<template #help>
<p class="text-xs text-neutral-400">仅支持 .pptx 格式</p>
</template>
<FileDnD
accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"
@change="(file) => (selected_file = file)"
/>
</UFormGroup>
<UAccordion
:items="[{ label: '高级选项' }]"
color="gray"
size="lg"
>
<template #item>
<div
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<UFormGroup
label="生成线路"
name="gen_server"
>
<USelectMenu
v-model="createCourseState.gen_server"
:options="[
{
label: '主线路',
value: 'main',
},
{
label: '备用线路',
value: 'standby1',
},
]"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UFormGroup
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<URange
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormGroup>
</div>
</template>
</UAccordion>
</UForm>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="gray"
label="取消"
size="lg"
variant="ghost"
@click="slide.close()"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
<ModalVideoTitleSelect
:is-open="isTitlesSelectorOpen"
@close="isTitlesSelectorOpen = false"
@select="
(titles) => {
selected_titles = titles as TitlesTemplate
}
"
/>
</USlideover>
</template>
<style scoped></style>

View File

@@ -1,302 +0,0 @@
<script lang="ts" setup>
import { type InferType, number, object, string } from 'yup'
import ModalDigitalHumanSelect from '~/components/ModalDigitalHumanSelect.vue'
import type { FormSubmitEvent } from '#ui/types'
import { useFetchWrapped } from '~/composables/useFetchWrapped'
const emit = defineEmits(['success'])
const slide = useSlideover()
const toast = useToast()
const loginState = useLoginState()
const creationForm = ref<HTMLFormElement>()
const creationPending = ref(false)
const isDigitalSelectorOpen = ref(false)
const createCourseSchema = object({
title: string()
.trim()
.min(4, '标题必须大于4个字符')
.max(20, '标题不能超过20个字符')
.required('请输入视频标题'),
content: string()
.trim()
.min(4, '内容必须大于4个字符')
.max(1000, '内容不能超过1000个字符')
.required('请输入驱动文本内容'),
digital_human_id: number().not([0], '请选择数字人'),
source_type: number().default(0).required(),
speed: number().default(1.0).min(0.5).max(1.5).required(),
bg_img: string().optional(),
})
type CreateCourseSchema = InferType<typeof createCourseSchema>
const createCourseState = reactive({
title: undefined,
content: undefined,
digital_human_id: 0,
source_type: 0,
speed: 1.0,
bg_img: '',
})
const selected_digital_human = ref<DigitalHumanItem | null>(null)
const selected_bg_img = ref<File | undefined>()
const enableBackgroundCompositing = ref(false)
watchEffect(() => {
if (selected_digital_human.value) {
// 2025.02.26 使用内部数字人 ID
createCourseState.digital_human_id =
selected_digital_human.value.digital_human_id ??
selected_digital_human.value.id ??
0
createCourseState.source_type = selected_digital_human.value.type!
}
})
watchEffect(() => {
// 根据背景合成开关更新 bg_img
createCourseState.bg_img = enableBackgroundCompositing.value
? 'https://service1.fenshenzhike.com/default_background.png'
: ''
})
const onCreateCourseGreenSubmit = async (
event: FormSubmitEvent<CreateCourseSchema>
) => {
creationPending.value = true
let payload: {
token: string
user_id: number
title: string
content: string
digital_human_id: any
speed: number
device_id: string
source_type: 1 | 2 | undefined
bg_img?: string
} = {
token: loginState.token!,
user_id: loginState.user.id,
title: event.data.title,
content: event.data.content,
digital_human_id: event.data.digital_human_id,
speed: 2 - event.data.speed,
device_id: 'XSHAssistant Web',
source_type: event.data.source_type as 1 | 2 | undefined,
bg_img: event.data.bg_img,
}
useFetchWrapped<
req.gen.GBVideoCreate & AuthedRequest,
BaseResponse<resp.gen.GBVideoCreate>
>('App.Digital_VideoTask.Create', payload)
.then((res) => {
if (!!res.data.task_id) {
toast.add({
title: '创建成功',
description: '视频已加入生成队列',
color: 'green',
icon: 'i-tabler-check',
})
emit('success')
slide.close()
} else {
toast.add({
title: '创建失败',
description: res.msg || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
creationPending.value = false
})
.catch((e) => {
creationPending.value = false
toast.add({
title: '创建失败',
description: e.message || '未知错误',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
})
}
</script>
<template>
<USlideover prevent-close>
<UCard
:ui="{
body: { base: 'flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex items-center justify-between">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
新建绿幕视频
</h3>
<UButton
class="-my-1"
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="slide.close()"
/>
</div>
</template>
<UForm
ref="creationForm"
:schema="createCourseSchema"
:state="createCourseState"
class="space-y-4"
@submit="onCreateCourseGreenSubmit"
>
<div class="flex justify-between gap-2 *:flex-1">
<UFormGroup
label="视频标题"
name="title"
required
>
<UInput
v-model="createCourseState.title"
placeholder="请输入视频标题"
/>
</UFormGroup>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<UFormGroup
label="数字人"
name="digital_human_id"
required
>
<div
:class="{ 'shadow-inner': !!selected_digital_human }"
class="flex items-center gap-2 bg-neutral-100 dark:bg-neutral-800 p-2 rounded-md cursor-pointer select-none transition-all"
@click="isDigitalSelectorOpen = true"
>
<div
class="w-12 aspect-square border dark:border-neutral-700 rounded-md flex justify-center items-center overflow-hidden"
>
<UIcon
v-if="!selected_digital_human"
class="text-2xl opacity-50"
name="i-tabler-user-screen"
/>
<NuxtImg
v-else
:src="selected_digital_human?.avatar"
/>
</div>
<div class="flex flex-col text-neutral-400 text-sm font-medium">
<span
:class="!!selected_digital_human ? 'text-neutral-600' : ''"
>
{{ selected_digital_human?.name || '点击选择数字人' }}
</span>
<span
v-if="selected_digital_human?.description"
class="text-2xs"
>
{{ selected_digital_human?.description }}
</span>
</div>
</div>
</UFormGroup>
</div>
<!-- <UFormGroup label="背景图片" name="bg_img" help="可以上传图片作为视频背景,留空则为绿幕背景">
<UInput type="file" accept="image/jpg,image/png" placeholder="选择背景图片" @change="selected_bg_img = $event?.[0] || undefined"/>
</UFormGroup> -->
<UFormGroup
label="驱动内容"
name="content"
required
>
<UTextarea
v-model="createCourseState.content"
:rows="6"
autoresize
placeholder="请输入驱动文本内容"
/>
</UFormGroup>
<UFormGroup
label="启用背景合成"
name="bg_img"
help="开启后生成透明通道,可在视频生成完毕后选择自定义背景合成;关闭则使用绿幕背景。"
>
<UToggle v-model="enableBackgroundCompositing" />
</UFormGroup>
<UAccordion
:items="[{ label: '高级选项' }]"
color="gray"
size="lg"
>
<template #item>
<div
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<UFormGroup
:label="`视频倍速:${createCourseState.speed}`"
name="speed"
>
<URange
v-model="createCourseState.speed"
:max="1.5"
:min="0.5"
:step="0.1"
class="pt-4"
size="sm"
/>
</UFormGroup>
</div>
</template>
</UAccordion>
</UForm>
<template #footer>
<div class="flex justify-end space-x-4">
<UButton
color="gray"
label="取消"
size="lg"
variant="ghost"
@click="slide.close()"
/>
<UButton
:loading="creationPending"
color="primary"
label="提交"
size="lg"
variant="solid"
@click="creationForm?.submit()"
/>
</div>
</template>
</UCard>
<ModalDigitalHumanSelect
:is-open="isDigitalSelectorOpen"
@close="isDigitalSelectorOpen = false"
@select="
(digitalHumans) => {
selected_digital_human = digitalHumans as DigitalHumanItem
}
"
/>
</USlideover>
</template>
<style scoped></style>

View File

@@ -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: 'rounded-full' }"
@click="emit('select', null)"
>
<span class="-mt-0.5">直接开始</span>
<template #trailing>
<span
class="group-hover:translate-x-1 transition duration-300 ease-out relative w-3 h-full -mt-0.5"
>
<UIcon
name="i-tabler-arrow-right"
class="w-5 h-5 absolute top-auto bottom-auto right-0 opacity-0 group-hover:opacity-100 transition duration-300"
/>
<UIcon
name="i-tabler-chevron-right"
class="w-5 h-5 absolute top-auto bottom-auto right-0 -mr-[3.5px] group-hover:opacity-0 transition duration-300"
/>
</span>
</template>
</UButton>
</div>
<div
class="w-full md:w-3/4 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 overflow-y-auto p-4 md:p-8"
>
<div
v-for="assistant in assistantTemplates?.data.items || []"
:key="assistant.id"
class="assistant-item select-none"
@click="emit('select', assistant)"
>
<div class="flex flex-col gap-1">
<div class="text-base font-medium">{{ assistant.tpl_name }}</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400">
{{ assistant.des }}
</div>
</div>
</div>
</div>
</div>
</template>
<!--suppress CssUnusedSymbol -->
<style scoped>
.loading-screen-leave-active {
@apply transition duration-300;
}
.loading-screen-leave-to {
@apply opacity-0;
}
.assistant-item {
@apply w-full bg-white dark:bg-neutral-800 rounded-lg shadow-sm ring-primary ring-offset-2 dark:ring-offset-0 hover:ring-2 transition;
@apply flex items-center gap-4 px-4 py-2 cursor-pointer border dark:border-neutral-700 hover:border-transparent;
}
</style>

View File

@@ -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 flex flex-col gap-1 shadow"
>
<div class="flex items-center gap-1 text-sm">
<UIcon
v-if="icon"
:name="icon"
class="text-base inline-block"
/>
<div
class="flex-1 flex items-center truncate whitespace-nowrap overflow-hidden"
>
<span>{{ label }}</span>
<UTooltip
v-if="comment"
:popper="{ arrow: true, placement: 'right' }"
:text="comment"
>
<UIcon
class="text-base"
name="i-tabler-help"
/>
</UTooltip>
</div>
<slot name="actions" />
</div>
<div class="flex flex-col gap-2">
<slot />
</div>
</div>
</template>
<style scoped></style>

View File

@@ -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-gradient-to-t from-neutral-800/40 to-transparent w-full h-full flex items-end scale-105 opacity-0 group-hover:scale-100 group-hover:opacity-100 transition"
>
<div class="w-full flex justify-end gap-1 p-1">
<UTooltip text="以此图为参考创作">
<UButton
color="indigo"
variant="soft"
size="2xs"
icon="i-tabler-copy"
square
@click="handle_use_reference(url)"
></UButton>
</UTooltip>
<UTooltip text="下载">
<UButton
color="indigo"
variant="soft"
size="2xs"
icon="i-tabler-download"
square
@click="handle_download(url)"
></UButton>
</UTooltip>
</div>
</div>
<img
class="result-image"
:src="useBlobUrlFromB64(url)"
alt="AI Generated"
/>
</div>
</div>
<div
v-else
class="h-64 aspect-[3/4] mb-4 rounded-lg placeholder-gradient flex justify-center items-center"
>
<UIcon
name="i-svg-spinners-tadpole"
class="text-3xl"
/>
</div>
<Transition
v-if="meta"
name="meta"
>
<div
v-if="show_meta"
class="w-full flex items-center gap-2 flex-wrap whitespace-nowrap pb-2 mt-2"
>
<UBadge
v-if="meta.modal"
color="black"
variant="solid"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-box-seam"
/>
{{ meta.modal }}
</UBadge>
<UBadge
v-if="meta.style"
color="green"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-christmas-tree"
/>
{{ meta.style }}
</UBadge>
<UBadge
v-if="meta.cost"
color="amber"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-solar-fire-bold"
/>
{{ meta.cost }}
</UBadge>
<UBadge
v-if="meta.ratio"
color="indigo"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-aspect-ratio"
/>
{{ meta.ratio }}
</UBadge>
<UBadge
v-if="meta.id"
color="indigo"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-number"
/>
{{ meta.id }}
</UBadge>
<UBadge
v-if="meta.datetime"
color="indigo"
variant="subtle"
class="text-[10px] font-bold gap-0.5"
>
<UIcon
class="text-sm"
name="i-tabler-calendar-month"
/>
{{ dayjs(meta.datetime * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</UBadge>
</div>
</Transition>
</div>
</template>
<style scoped>
.meta-enter-active,
.meta-leave-active {
@apply transition duration-300;
}
.meta-enter-from,
.meta-leave-to {
@apply opacity-0 -translate-y-2;
}
.result-image {
@apply snap-start;
@apply w-full h-full object-cover;
}
.placeholder-gradient {
@apply animate-pulse bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-700 dark:to-neutral-800;
}
</style>

View File

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

View File

@@ -1,623 +0,0 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import { encode } from '@monosky/base64'
import { object, string, number, type InferType } from 'yup'
interface Subtitle {
start: string
end: string
text: string
active?: boolean
}
const props = defineProps({
course: {
type: Object as PropType<resp.gen.CourseGenItem>,
required: true,
},
})
const dayjs = useDayjs()
const toast = useToast()
const loginState = useLoginState()
const isDrawerActive = ref(false)
const isLoading = ref(true)
const isSaving = ref(false)
const rawSrt = ref<string | null>(null)
const subtitles = ref<Subtitle[]>([])
const modified = ref(false)
const isExporting = ref(false)
const videoElement = ref<HTMLVideoElement | null>(null)
const subtitleStyleSchema = object({
color: string().required(),
fontSize: number().required(),
effect: string().required(),
bottomOffset: number().required(),
})
type subtitleStyleSchema = InferType<typeof subtitleStyleSchema>
const subtitleStyleState = reactive<subtitleStyleSchema>({
color: '#fff',
effect: 'shadow',
fontSize: 24,
bottomOffset: 12,
})
const loadSrt = async () => {
isLoading.value = true
try {
// const response = await fetch(props.course.subtitle_url)
const response = await fetch(await fetchCourseSubtitleUrl(props.course))
const text = await response.text()
rawSrt.value = text
parseSrt(text)
} catch (err) {
toast.add({
title: '加载字幕失败',
description: `${err}` || '未知错误',
color: 'red',
})
} finally {
isLoading.value = false
}
}
const parseSrt = (srt: string) => {
const lines = srt.split(/\r?\n/)
const regex = /(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/
let subtitle: Subtitle | null = null
lines.forEach((line) => {
if (/^\d+$/.test(line.trim())) return
const match = line.match(regex)
if (match) {
if (subtitle) {
subtitles.value.push(subtitle)
}
subtitle = {
start: match[1],
end: match[2],
text: '',
}
} else if (subtitle) {
subtitle.text += line.trim() ? line : ''
}
})
if (subtitle) {
subtitles.value.push(subtitle)
}
}
const generateSrt = () => {
return subtitles.value
.map((subtitle, index) => {
return `${index + 1}\n${subtitle.start} --> ${subtitle.end}\n${
subtitle.text
}\n`
})
.join('\n')
}
const formatTime = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0].split(':')
return {
hours: parseInt(timeParts[0]),
minutes: parseInt(timeParts[1]),
seconds: parseInt(timeParts[2]),
milliseconds: parseInt(parts[1]),
}
}
const formatTimeToDayjs = (time: string) => {
const parts = time.split(',')
const timeParts = parts[0].split(':')
return dayjs()
.hour(parseInt(timeParts[0]))
.minute(parseInt(timeParts[1]))
.second(parseInt(timeParts[2]))
.millisecond(parseInt(parts[1]))
}
const syncSubtitles = () => {
if (!videoElement.value) return
const currentTime = videoElement.value.currentTime * 1000 // convert to milliseconds
subtitles.value.forEach((subtitle) => {
const start = formatTime(subtitle.start)
const end = formatTime(subtitle.end)
const startTime =
(start.hours * 3600 + start.minutes * 60 + start.seconds) * 1000 +
start.milliseconds
const endTime =
(end.hours * 3600 + end.minutes * 60 + end.seconds) * 1000 +
end.milliseconds
subtitle.active = currentTime >= startTime && currentTime <= endTime
// scroll active subtitle into view
if (subtitle.active) {
const element = document.getElementById(
`subtitle-${subtitles.value.indexOf(subtitle)}`
)!
const parent = element?.parentElement
// scroll element to the center of parent
parent?.scrollTo({
top: element.offsetTop,
})
}
})
}
const onSubtitleInputClick = (subtitle: Subtitle) => {
if (!videoElement.value) return
if (!subtitle.active) {
videoElement.value.currentTime =
formatTime(subtitle.start).hours * 3600 +
formatTime(subtitle.start).minutes * 60 +
formatTime(subtitle.start).seconds +
1
}
videoElement.value.pause()
}
const saveNewSubtitle = () => {
isSaving.value = true
const encodedSubtitle = encode(generateSrt())
useFetchWrapped<
req.gen.CourseSubtitleCreate & AuthedRequest,
BaseResponse<resp.gen.CourseSubtitleCreate>
>('App.Digital_VideoSubtitle.CreateFile', {
token: loginState.token!,
user_id: loginState.user.id,
sub_type: 1,
sub_content: encodedSubtitle,
task_id: props.course?.task_id,
})
.then((_) => {
modified.value = false
toast.add({
color: 'green',
title: '字幕已保存',
description: '修改后的字幕文件已保存',
})
})
.finally(() => {
isSaving.value = false
})
}
const exportVideo = async () => {
isExporting.value = true
const srtResponse = await (
await fetch(await fetchCourseSubtitleUrl(props.course))
).blob()
if (!srtResponse) {
toast.add({
title: '获取字幕失败',
description: '无法获取字幕文件,请稍后重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
const srtBlob = new Blob([srtResponse], { type: 'text/plain' })
const srtUrl = URL.createObjectURL(srtBlob)
useVideoSubtitleEmbedding(props.course.video_url, srtUrl, {
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize,
textShadow:
subtitleStyleState.effect === 'shadow'
? {
offsetX: 2,
offsetY: 2,
blur: 6,
color: 'rgba(0, 0, 0, 0.35)',
}
: {
offsetX: 0,
offsetY: 0,
blur: 0,
color: 'transparent',
},
strokeStyle: subtitleStyleState.effect === 'stroke' ? '#000 2px' : 'none',
bottomOffset: subtitleStyleState.bottomOffset,
})
.then((blobUrl) => {
const { download } = useDownload(blobUrl, 'combined_video.mp4')
download()
})
.finally(() => {
isExporting.value = false
})
}
onMounted(() => {
if (rawSrt.value) {
parseSrt(rawSrt.value)
}
})
defineExpose({
open() {
isDrawerActive.value = true
if (!rawSrt.value) loadSrt()
},
close() {
isDrawerActive.value = false
},
})
</script>
<template>
<div>
<USlideover
v-model="isDrawerActive"
:prevent-close="modified"
:ui="{ width: 'max-w-lg' }"
>
<UCard
class="flex flex-col flex-1 overflow-hidden"
:ui="{
body: { base: 'overflow-auto flex-1' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<UButton
color="gray"
variant="ghost"
size="sm"
icon="tabler:x"
class="flex sm:hidden absolute end-5 top-5 z-10"
square
padded
@click="isDrawerActive = false"
/>
<div class="flex flex-col">
<h3
class="text-base font-semibold leading-6 text-gray-900 dark:text-white"
>
字幕编辑器
</h3>
<h3
class="text-xs font-semibold text-blue-500"
v-if="course.title"
>
{{ course.title }}
</h3>
</div>
</template>
<div
v-if="isLoading"
class="flex justify-center items-center text-primary"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
>
<defs>
<filter id="svgSpinnersGooeyBalls20">
<feGaussianBlur
in="SourceGraphic"
result="y"
stdDeviation="1"
/>
<feColorMatrix
in="y"
result="z"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"
/>
<feBlend
in="SourceGraphic"
in2="z"
/>
</filter>
</defs>
<g filter="url(#svgSpinnersGooeyBalls20)">
<circle
cx="5"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="5;8;5"
/>
</circle>
<circle
cx="19"
cy="12"
r="4"
fill="currentColor"
>
<animate
attributeName="cx"
calcMode="spline"
dur="2s"
keySplines=".36,.62,.43,.99;.79,0,.58,.57"
repeatCount="indefinite"
values="19;16;19"
/>
</circle>
<animateTransform
attributeName="transform"
dur="0.75s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"
/>
</g>
</svg>
</div>
<div
v-else
class="flex flex-col h-full gap-2 overflow-hidden overscroll-y-none overshadow"
>
<div class="relative w-full aspect-video flex-1">
<div
class="absolute w-fit mx-auto inset-x-0 font-sans font-bold subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
{{ subtitles.find((sub) => sub.active)?.text }}
</div>
<video
controls
ref="videoElement"
class="rounded"
style="-webkit-user-drag: none"
:src="course.video_url"
@timeupdate="syncSubtitles"
/>
</div>
<UAccordion
:items="[{ label: '字幕选项' }]"
color="gray"
size="lg"
>
<template #item>
<div
class="border dark:border-neutral-700 rounded-lg space-y-4 p-4 pb-6"
>
<div class="w-full flex flex-col justify-center">
<div
class="rounded-md w-full aspect-video relative overflow-hidden"
>
<img
class="object-cover w-full h-full rounded-md"
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-04/9ed1e5c0133824f0bcf79d1ad9e9ecbb.png"
/>
<span
class="absolute font-sans font-bold bottom-0 left-1/2 transform -translate-x-1/2 subtitle"
:class="{
stroke: subtitleStyleState.effect === 'stroke',
}"
:style="{
lineHeight: '1',
color: subtitleStyleState.color,
fontSize: subtitleStyleState.fontSize / 1.5 + 'px',
bottom: subtitleStyleState.bottomOffset / 1.5 + 'px',
textShadow:
subtitleStyleState.effect === 'shadow'
? '2px 2px 4px rgba(0, 0, 0, 0.25)'
: undefined,
}"
>
字幕样式预览
</span>
</div>
<span class="text-sm italic opacity-50">
字幕预览仅供参考以实际渲染效果为准
</span>
</div>
<UForm
:schema="subtitleStyleSchema"
:state="subtitleStyleState"
class="flex flex-col gap-4"
>
<div class="flex gap-4">
<UFormGroup
label="字幕颜色"
name="fontColor"
class="w-full"
size="xs"
>
<USelectMenu
:options="[
{
label: '黑色',
value: '#000',
},
{
label: '白色',
value: '#fff',
},
]"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.color"
/>
</UFormGroup>
<UFormGroup
label="字幕效果"
name="effect"
class="w-full"
size="xs"
>
<USelectMenu
:options="[
{
label: '阴影',
value: 'shadow',
},
{
label: '描边',
value: 'stroke',
},
]"
option-attribute="label"
value-attribute="value"
v-model="subtitleStyleState.effect"
/>
</UFormGroup>
</div>
<UFormGroup
:label="`字幕大小 ${subtitleStyleState.fontSize}px`"
name="fontSize"
size="xs"
>
<URange
:max="64"
:min="20"
:step="2"
size="sm"
v-model="subtitleStyleState.fontSize"
/>
</UFormGroup>
<UFormGroup
:label="`字幕偏移量 ${subtitleStyleState.bottomOffset}px`"
name="offset"
size="xs"
>
<URange
:max="30"
:min="0"
:step="1"
size="sm"
v-model="subtitleStyleState.bottomOffset"
/>
</UFormGroup>
</UForm>
</div>
</template>
</UAccordion>
<ul
class="flex-1 px-0.5 pb-[100%] overflow-y-auto space-y-0.5 scroll-smooth relative"
>
<li
v-for="(subtitle, index) in subtitles"
:key="index"
:id="'subtitle-' + index"
>
<div :class="{ 'text-primary': subtitle.active }">
<span class="text-xs font-medium opacity-60">
{{ formatTimeToDayjs(subtitle.start).format('HH:mm:ss') }}
-
{{ formatTimeToDayjs(subtitle.end).format('HH:mm:ss') }}
<span class="opacity-50">
[{{
formatTimeToDayjs(subtitle.end).diff(
formatTimeToDayjs(subtitle.start),
'second'
)
}}s]
</span>
</span>
<UInput
v-model="subtitle.text"
class="w-full"
placeholder="请输入字幕内容"
:name="'subtitle-' + index"
:autofocus="false"
:color="subtitle.active ? 'primary' : undefined"
@click="onSubtitleInputClick(subtitle)"
@input="
() => {
if (!modified) modified = true
}
"
>
<template #trailing>
<UIcon
v-show="subtitle.active"
name="tabler:keyframe-align-vertical-filled"
/>
</template>
</UInput>
</div>
</li>
</ul>
</div>
<template #footer>
<div class="flex justify-end items-center gap-2">
<span
v-if="modified"
class="text-sm text-yellow-500 font-medium"
>
已更改但未保存
</span>
<UButton
:loading="isExporting"
variant="soft"
icon="i-tabler-file-export"
@click="exportVideo"
>
导出视频
</UButton>
<UButton
:disabled="isExporting || !modified"
:loading="isSaving"
icon="i-tabler-device-floppy"
@click="saveNewSubtitle"
>
保存{{ isSaving ? '中' : '' }}
</UButton>
</div>
</template>
</UCard>
</USlideover>
</div>
</template>
<style scoped>
.overshadow {
@apply relative;
}
.overshadow:after {
content: '';
inset: 80% 0 0;
position: absolute;
@apply bg-gradient-to-b from-transparent to-white dark:to-neutral-950 pointer-events-none;
}
.subtitle.stroke {
text-shadow:
1px 1px 0 #000,
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000;
}
.subtitle.shadow {
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
}
</style>

View File

@@ -4,6 +4,8 @@ export default defineNuxtConfig({
ssr: false,
css: ['@/assets/css/main.css'],
runtimeConfig: {
public: {
API_BASE: 'https://service1.fenshenzhike.com/',
@@ -15,7 +17,7 @@ export default defineNuxtConfig({
'radix-vue/nuxt',
'dayjs-nuxt',
'@pinia/nuxt',
'@pinia-plugin-persistedstate/nuxt',
'pinia-plugin-persistedstate',
'@vite-pwa/nuxt',
'@nuxtjs/google-fonts',
'@nuxt/image',

View File

@@ -1,6 +1,6 @@
{
"name": "xsh-assistant",
"version": "0.1.6",
"name": "xsh-assistant-next",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
@@ -12,7 +12,6 @@
"lint:fix": "oxlint --fix",
"postinstall": "nuxt prepare"
},
"packageManager": "pnpm@10.22.0",
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
@@ -31,33 +30,40 @@
"highlight.js": "^11.10.0",
"idb-keyval": "^6.2.1",
"markdown-it": "^14.1.0",
"nuxt": "^3.12.4",
"nuxt-driver.js": "^0.0.11",
"nuxt": "^4.3.1",
"nuxt-driver.js": "^0.0.24",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"radix-vue": "^1.9.2",
"v-calendar": "^3.1.2",
"vue": "^3.4.34",
"vue": "^3.5.28",
"vue-router": "^4.4.0",
"yup": "^1.4.0"
},
"devDependencies": {
"@nuxt/ui": "^2.20.0",
"@nuxt/ui": "^4.4.0",
"@nuxtjs/google-fonts": "^3.2.0",
"@pinia-plugin-persistedstate/nuxt": "^1.2.1",
"@pinia/nuxt": "^0.5.2",
"@tailwindcss/typography": "^0.5.13",
"@pinia/nuxt": "^0.11.3",
"@tailwindcss/typography": "^0.5.19",
"@types/markdown-it": "^13.0.9",
"@types/node": "^25.2.2",
"@vite-pwa/nuxt": "^0.5.0",
"@vueuse/core": "^10.11.1",
"@vueuse/nuxt": "^10.11.1",
"dayjs-nuxt": "^2.1.9",
"@vite-pwa/nuxt": "^1.1.1",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"dayjs-nuxt": "^2.1.11",
"oxfmt": "^0.28.0",
"oxlint": "^1.43.0",
"sass": "^1.77.8",
"sass": "^1.97.3",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},
"peerDependencies": {
"dayjs": "^1.11.12",
"tailwindcss": "^3.4.7"
"dayjs": "^1.11.19"
},
"packageManager": "pnpm@10.22.0",
"pnpm": {
"overrides": {
"citty": "0.1.6"
}
}
}

Some files were not shown because too many files have changed in this diff Show More