Merge remote-tracking branch 'yudao/master'
# Conflicts: # src/api/infra/demo/demo03/erp/index.ts # src/api/infra/demo/demo03/inner/index.ts # src/api/infra/demo/demo03/normal/index.ts # src/views/infra/demo/demo03/erp/components/Demo03CourseList.vue # src/views/infra/demo/demo03/erp/components/Demo03GradeList.vue # src/views/infra/demo/demo03/erp/index.vue
This commit is contained in:
@@ -2,10 +2,12 @@
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<el-row :gutter="20" justify="space-between">
|
||||
<el-row :gutter="16" justify="space-between">
|
||||
<el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<div class="flex items-center">
|
||||
<img :src="avatar" alt="" class="mr-20px h-70px w-70px rounded-[50%]" />
|
||||
<el-avatar :src="avatar" :size="70" class="mr-16px">
|
||||
<img src="@/assets/imgs/avatar.gif" alt="" />
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="text-20px">
|
||||
{{ t('workplace.welcome') }} {{ username }} {{ t('workplace.happyDay') }}
|
||||
@@ -19,7 +21,7 @@
|
||||
<el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24">
|
||||
<div class="h-70px flex items-center justify-end lt-sm:mt-10px">
|
||||
<div class="px-8px text-right">
|
||||
<div class="mb-20px text-14px text-gray-400">{{ t('workplace.project') }}</div>
|
||||
<div class="mb-16px text-14px text-gray-400">{{ t('workplace.project') }}</div>
|
||||
<CountTo
|
||||
class="text-20px"
|
||||
:start-val="0"
|
||||
@@ -29,7 +31,7 @@
|
||||
</div>
|
||||
<el-divider direction="vertical" />
|
||||
<div class="px-8px text-right">
|
||||
<div class="mb-20px text-14px text-gray-400">{{ t('workplace.toDo') }}</div>
|
||||
<div class="mb-16px text-14px text-gray-400">{{ t('workplace.toDo') }}</div>
|
||||
<CountTo
|
||||
class="text-20px"
|
||||
:start-val="0"
|
||||
@@ -39,7 +41,7 @@
|
||||
</div>
|
||||
<el-divider direction="vertical" border-style="dashed" />
|
||||
<div class="px-8px text-right">
|
||||
<div class="mb-20px text-14px text-gray-400">{{ t('workplace.access') }}</div>
|
||||
<div class="mb-16px text-14px text-gray-400">{{ t('workplace.access') }}</div>
|
||||
<CountTo
|
||||
class="text-20px"
|
||||
:start-val="0"
|
||||
@@ -54,13 +56,20 @@
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-row class="mt-5px" :gutter="20" justify="space-between">
|
||||
<el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-10px">
|
||||
<el-row class="mt-8px" :gutter="8" justify="space-between">
|
||||
<el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-8px">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="h-3 flex justify-between">
|
||||
<span>{{ t('workplace.project') }}</span>
|
||||
<el-link type="primary" :underline="false">{{ t('action.more') }}</el-link>
|
||||
<el-link
|
||||
type="primary"
|
||||
:underline="false"
|
||||
href="https://github.com/yudaocode"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('action.more') }}
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
<el-skeleton :loading="loading" animated>
|
||||
@@ -74,13 +83,22 @@
|
||||
:sm="24"
|
||||
:xs="24"
|
||||
>
|
||||
<el-card shadow="hover">
|
||||
<el-card
|
||||
shadow="hover"
|
||||
class="mr-5px mt-5px cursor-pointer"
|
||||
@click="handleProjectClick(item.message)"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon :icon="item.icon" :size="25" class="mr-10px" />
|
||||
<Icon
|
||||
:icon="item.icon"
|
||||
:size="25"
|
||||
class="mr-8px"
|
||||
:style="{ color: item.color }"
|
||||
/>
|
||||
<span class="text-16px">{{ item.name }}</span>
|
||||
</div>
|
||||
<div class="mt-15px text-14px text-gray-400">{{ t(item.message) }}</div>
|
||||
<div class="mt-20px flex justify-between text-12px text-gray-400">
|
||||
<div class="mt-12px text-12px text-gray-400">{{ t(item.message) }}</div>
|
||||
<div class="mt-12px flex justify-between text-12px text-gray-400">
|
||||
<span>{{ item.personal }}</span>
|
||||
<span>{{ formatTime(item.time, 'yyyy-MM-dd') }}</span>
|
||||
</div>
|
||||
@@ -90,18 +108,18 @@
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-5px">
|
||||
<el-card shadow="never" class="mt-8px">
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<el-row :gutter="20" justify="space-between">
|
||||
<el-col :xl="10" :lg="10" :md="24" :sm="24" :xs="24">
|
||||
<el-card shadow="hover" class="mb-10px">
|
||||
<el-card shadow="hover" class="mb-8px">
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<Echart :options="pieOptionsData" :height="280" />
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xl="14" :lg="14" :md="24" :sm="24" :xs="24">
|
||||
<el-card shadow="hover" class="mb-10px">
|
||||
<el-card shadow="hover" class="mb-8px">
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<Echart :options="barOptionsData" :height="280" />
|
||||
</el-skeleton>
|
||||
@@ -111,7 +129,7 @@
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-10px">
|
||||
<el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-8px">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="h-3 flex justify-between">
|
||||
@@ -120,10 +138,10 @@
|
||||
</template>
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<el-row>
|
||||
<el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-10px">
|
||||
<el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-8px">
|
||||
<div class="flex items-center">
|
||||
<Icon :icon="item.icon" class="mr-10px" />
|
||||
<el-link type="default" :underline="false" @click="setWatermark(item.name)">
|
||||
<Icon :icon="item.icon" class="mr-8px" :style="{ color: item.color }" />
|
||||
<el-link type="default" :underline="false" @click="handleShortcutClick(item.url)">
|
||||
{{ item.name }}
|
||||
</el-link>
|
||||
</div>
|
||||
@@ -131,7 +149,7 @@
|
||||
</el-row>
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-10px">
|
||||
<el-card shadow="never" class="mt-8px">
|
||||
<template #header>
|
||||
<div class="h-3 flex justify-between">
|
||||
<span>{{ t('workplace.notice') }}</span>
|
||||
@@ -141,14 +159,16 @@
|
||||
<el-skeleton :loading="loading" animated>
|
||||
<div v-for="(item, index) in notice" :key="`dynamics-${index}`">
|
||||
<div class="flex items-center">
|
||||
<img :src="avatar" alt="" class="mr-20px h-35px w-35px rounded-[50%]" />
|
||||
<el-avatar :src="avatar" :size="35" class="mr-16px">
|
||||
<img src="@/assets/imgs/avatar.gif" alt="" />
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="text-14px">
|
||||
<Highlight :keys="item.keys.map((v) => t(v))">
|
||||
{{ item.type }} : {{ item.title }}
|
||||
</Highlight>
|
||||
</div>
|
||||
<div class="mt-15px text-12px text-gray-400">
|
||||
<div class="mt-16px text-12px text-gray-400">
|
||||
{{ formatTime(item.date, 'yyyy-MM-dd') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,18 +186,19 @@ import { EChartsOption } from 'echarts'
|
||||
import { formatTime } from '@/utils'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useWatermark } from '@/hooks/web/useWatermark'
|
||||
import avatarImg from '@/assets/imgs/avatar.gif'
|
||||
// import { useWatermark } from '@/hooks/web/useWatermark'
|
||||
import type { WorkplaceTotal, Project, Notice, Shortcut } from './types'
|
||||
import { pieOptions, barOptions } from './echarts-data'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'Home' })
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { setWatermark } = useWatermark()
|
||||
// const { setWatermark } = useWatermark()
|
||||
const loading = ref(true)
|
||||
const avatar = userStore.getUser.avatar ? userStore.getUser.avatar : avatarImg
|
||||
const avatar = userStore.getUser.avatar
|
||||
const username = userStore.getUser.nickname
|
||||
const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption
|
||||
// 获取统计数
|
||||
@@ -201,46 +222,52 @@ let projects = reactive<Project[]>([])
|
||||
const getProject = async () => {
|
||||
const data = [
|
||||
{
|
||||
name: 'Github',
|
||||
icon: 'akar-icons:github-fill',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
name: 'ruoyi-vue-pro',
|
||||
icon: 'simple-icons:springboot',
|
||||
message: 'github.com/YunaiV/ruoyi-vue-pro',
|
||||
personal: 'Spring Boot 单体架构',
|
||||
time: new Date('2025-01-02'),
|
||||
color: '#6DB33F'
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: 'logos:vue',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
name: 'yudao-ui-admin-vue3',
|
||||
icon: 'ep:element-plus',
|
||||
message: 'github.com/yudaocode/yudao-ui-admin-vue3',
|
||||
personal: 'Vue3 + element-plus 管理后台',
|
||||
time: new Date('2025-02-03'),
|
||||
color: '#409EFF'
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
icon: 'logos:angular-icon',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
name: 'yudao-ui-mall-uniapp',
|
||||
icon: 'icon-park-outline:mall-bag',
|
||||
message: 'github.com/yudaocode/yudao-ui-mall-uniapp',
|
||||
personal: 'Vue3 + uniapp 商城手机端',
|
||||
time: new Date('2025-03-04'),
|
||||
color: '#ff4d4f'
|
||||
},
|
||||
{
|
||||
name: 'React',
|
||||
icon: 'logos:react',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
name: 'yudao-cloud',
|
||||
icon: 'material-symbols:cloud-outline',
|
||||
message: 'github.com/YunaiV/yudao-cloud',
|
||||
personal: 'Spring Cloud 微服务架构',
|
||||
time: new Date('2025-04-05'),
|
||||
color: '#1890ff'
|
||||
},
|
||||
{
|
||||
name: 'Webpack',
|
||||
icon: 'logos:webpack',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
name: 'yudao-ui-admin-vben',
|
||||
icon: 'devicon:antdesign',
|
||||
message: 'github.com/yudaocode/yudao-ui-admin-vben',
|
||||
personal: 'Vue3 + vben5(antd) 管理后台',
|
||||
time: new Date('2025-05-06'),
|
||||
color: '#e18525'
|
||||
},
|
||||
{
|
||||
name: 'Vite',
|
||||
icon: 'vscode-icons:file-type-vite',
|
||||
message: 'workplace.introduction',
|
||||
personal: 'Archer',
|
||||
time: new Date()
|
||||
name: 'yudao-ui-admin-uniapp',
|
||||
icon: 'ant-design:mobile',
|
||||
message: 'github.com/yudaocode/yudao-ui-admin-uniapp',
|
||||
personal: 'Vue3 + uniapp 管理手机端',
|
||||
time: new Date('2025-06-01'),
|
||||
color: '#2979ff'
|
||||
}
|
||||
]
|
||||
projects = Object.assign(projects, data)
|
||||
@@ -251,27 +278,27 @@ let notice = reactive<Notice[]>([])
|
||||
const getNotice = async () => {
|
||||
const data = [
|
||||
{
|
||||
title: '系统升级版本',
|
||||
type: '通知',
|
||||
keys: ['通知', '升级'],
|
||||
title: '系统支持 JDK 8/17/21,Vue 2/3',
|
||||
type: '技术兼容性',
|
||||
keys: ['JDK', 'Vue'],
|
||||
date: new Date()
|
||||
},
|
||||
{
|
||||
title: '系统凌晨维护',
|
||||
type: '公告',
|
||||
keys: ['公告', '维护'],
|
||||
title: '后端提供 Spring Boot 2.7/3.2 + Cloud 双架构',
|
||||
type: '架构灵活性',
|
||||
keys: ['Boot', 'Cloud'],
|
||||
date: new Date()
|
||||
},
|
||||
{
|
||||
title: '系统升级版本',
|
||||
type: '通知',
|
||||
keys: ['通知', '升级'],
|
||||
title: '全部开源,个人与企业可 100% 直接使用,无需授权',
|
||||
type: '开源免授权',
|
||||
keys: ['无需授权'],
|
||||
date: new Date()
|
||||
},
|
||||
{
|
||||
title: '系统凌晨维护',
|
||||
type: '公告',
|
||||
keys: ['公告', '维护'],
|
||||
title: '国内使用最广泛的快速开发平台,远超 10w+ 企业使用',
|
||||
type: '广泛企业认可',
|
||||
keys: ['最广泛', '10w+'],
|
||||
date: new Date()
|
||||
}
|
||||
]
|
||||
@@ -284,34 +311,40 @@ let shortcut = reactive<Shortcut[]>([])
|
||||
const getShortcut = async () => {
|
||||
const data = [
|
||||
{
|
||||
name: 'Github',
|
||||
icon: 'akar-icons:github-fill',
|
||||
url: 'github.io'
|
||||
name: '首页',
|
||||
icon: 'ion:home-outline',
|
||||
url: '/',
|
||||
color: '#1fdaca'
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: 'logos:vue',
|
||||
url: 'vuejs.org'
|
||||
name: '商城中心',
|
||||
icon: 'ep:shop',
|
||||
url: '/mall/home',
|
||||
color: '#ff6b6b'
|
||||
},
|
||||
{
|
||||
name: 'Vite',
|
||||
icon: 'vscode-icons:file-type-vite',
|
||||
url: 'https://vitejs.dev/'
|
||||
name: 'AI 大模型',
|
||||
icon: 'tabler:ai',
|
||||
url: '/ai/chat',
|
||||
color: '#7c3aed'
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
icon: 'logos:angular-icon',
|
||||
url: 'github.io'
|
||||
name: 'ERP 系统',
|
||||
icon: 'simple-icons:erpnext',
|
||||
url: '/erp/home',
|
||||
color: '#3fb27f'
|
||||
},
|
||||
{
|
||||
name: 'React',
|
||||
icon: 'logos:react',
|
||||
url: 'github.io'
|
||||
name: 'CRM 系统',
|
||||
icon: 'simple-icons:civicrm',
|
||||
url: '/crm/backlog',
|
||||
color: '#4daf1bc9'
|
||||
},
|
||||
{
|
||||
name: 'Webpack',
|
||||
icon: 'logos:webpack',
|
||||
url: 'github.io'
|
||||
name: 'IoT 物联网',
|
||||
icon: 'fa-solid:hdd',
|
||||
url: '/iot/home',
|
||||
color: '#1a73e8'
|
||||
}
|
||||
]
|
||||
shortcut = Object.assign(shortcut, data)
|
||||
@@ -377,5 +410,13 @@ const getAllApi = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const handleProjectClick = (message: string) => {
|
||||
window.open(`https://${message}`, '_blank')
|
||||
}
|
||||
|
||||
const handleShortcutClick = (url: string) => {
|
||||
router.push(url)
|
||||
}
|
||||
|
||||
getAllApi()
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Project = {
|
||||
message: string
|
||||
personal: string
|
||||
time: Date | number | string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type Notice = {
|
||||
@@ -23,6 +24,7 @@ export type Shortcut = {
|
||||
name: string
|
||||
icon: string
|
||||
url: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type RadarData = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
:class="prefixCls"
|
||||
class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px"
|
||||
class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px lt-xl:px-10px"
|
||||
>
|
||||
<div class="relative mx-auto h-full flex">
|
||||
<div
|
||||
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden`"
|
||||
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden overflow-x-hidden overflow-y-auto`"
|
||||
>
|
||||
<!-- 左上角的 logo + 系统标题 -->
|
||||
<div class="relative flex items-center text-white">
|
||||
@@ -27,24 +27,27 @@
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px">
|
||||
<div
|
||||
class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<!-- 右上角的主题、语言选择 -->
|
||||
<div
|
||||
class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end"
|
||||
class="flex items-center justify-between at-2xl:justify-end at-xl:justify-end"
|
||||
style="color: var(--el-text-color-primary);"
|
||||
>
|
||||
<div class="flex items-center at-2xl:hidden at-xl:hidden">
|
||||
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
|
||||
<span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
|
||||
<span class="text-20px font-bold" >{{ underlineToHump(appStore.getTitle) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-10px">
|
||||
<div class="flex items-center justify-end space-x-10px h-48px">
|
||||
<ThemeSwitch />
|
||||
<LocaleDropdown class="dark:text-white lt-xl:text-white" />
|
||||
<LocaleDropdown />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右边的登录界面 -->
|
||||
<Transition appear enter-active-class="animate__animated animate__bounceInRight">
|
||||
<div
|
||||
class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
|
||||
class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
|
||||
>
|
||||
<!-- 账号登录 -->
|
||||
<LoginForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
|
||||
@@ -56,6 +59,8 @@
|
||||
<RegisterForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
|
||||
<!-- 三方登录 -->
|
||||
<SSOLoginVue class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
|
||||
<!-- 忘记密码 -->
|
||||
<ForgetPasswordForm class="m-auto h-auto p-20px lt-xl:(rounded-3xl light:bg-white)" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -70,7 +75,7 @@ import { useAppStore } from '@/store/modules/app'
|
||||
import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
|
||||
import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
|
||||
|
||||
import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue } from './components'
|
||||
import { LoginForm, MobileForm, QrCodeForm, RegisterForm, SSOLoginVue, ForgetPasswordForm } from './components'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
@@ -102,3 +107,15 @@ $prefix-cls: #{$namespace}-login;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.dark .login-form {
|
||||
.el-divider__text {
|
||||
background-color: var(--login-bg-color);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
background-color: var(--login-bg-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
:class="prefixCls"
|
||||
class="relative h-[100%] lt-xl:bg-[var(--login-bg-color)] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px"
|
||||
class="relative h-[100%] lt-md:px-10px lt-sm:px-10px lt-xl:px-10px lt-xl:px-10px"
|
||||
>
|
||||
<div class="relative mx-auto h-full flex">
|
||||
<div
|
||||
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden`"
|
||||
:class="`${prefixCls}__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px lt-xl:hidden overflow-x-hidden overflow-y-auto`"
|
||||
>
|
||||
<!-- 左上角的 logo + 系统标题 -->
|
||||
<div class="relative flex items-center text-white">
|
||||
@@ -27,7 +27,9 @@
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px">
|
||||
<div
|
||||
class="relative flex-1 p-30px dark:bg-[var(--login-bg-color)] lt-sm:p-10px overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<!-- 右上角的主题、语言选择 -->
|
||||
<div
|
||||
class="flex items-center justify-between text-white at-2xl:justify-end at-xl:justify-end"
|
||||
@@ -36,7 +38,7 @@
|
||||
<img alt="" class="mr-10px h-48px w-48px" src="@/assets/imgs/logo.png" />
|
||||
<span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-10px">
|
||||
<div class="flex items-center justify-end space-x-10px h-48px">
|
||||
<ThemeSwitch />
|
||||
<LocaleDropdown class="dark:text-white lt-xl:text-white" />
|
||||
</div>
|
||||
@@ -44,7 +46,7 @@
|
||||
<!-- 右边的登录界面 -->
|
||||
<Transition appear enter-active-class="animate__animated animate__bounceInRight">
|
||||
<div
|
||||
class="m-auto h-full w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
|
||||
class="m-auto h-[calc(100%-60px)] w-[100%] flex items-center at-2xl:max-w-500px at-lg:max-w-500px at-md:max-w-500px at-xl:max-w-500px"
|
||||
>
|
||||
<!-- 账号登录 -->
|
||||
<el-form
|
||||
@@ -64,7 +66,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item v-if="loginData.tenantEnable === 'true'" prop="tenantName">
|
||||
<el-form-item v-if="loginData.tenantEnable" prop="tenantName">
|
||||
<el-input
|
||||
v-model="loginData.loginForm.tenantName"
|
||||
:placeholder="t('login.tenantNamePlaceholder')"
|
||||
@@ -112,9 +114,9 @@
|
||||
</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :offset="6" :span="12">
|
||||
<el-link style="float: right" type="primary">{{
|
||||
t('login.forgetPassword')
|
||||
}}</el-link>
|
||||
<el-link style="float: right" type="primary"
|
||||
>{{ t('login.forgetPassword') }}
|
||||
</el-link>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
@@ -131,6 +133,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<Verify
|
||||
v-if="loginData.captchaEnable === 'true'"
|
||||
ref="verify"
|
||||
:captchaType="captchaType"
|
||||
:imgSize="{ width: '400px', height: '200px' }"
|
||||
@@ -193,8 +196,8 @@ const LoginRules = {
|
||||
}
|
||||
const loginData = reactive({
|
||||
isShowPassword: false,
|
||||
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
|
||||
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
|
||||
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE !== 'false',
|
||||
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE !== 'false',
|
||||
loginForm: {
|
||||
tenantName: '芋道源码',
|
||||
username: 'admin',
|
||||
@@ -207,7 +210,7 @@ const loginData = reactive({
|
||||
// 获取验证码
|
||||
const getCode = async () => {
|
||||
// 情况一,未开启:则直接登录
|
||||
if (loginData.captchaEnable === 'false') {
|
||||
if (!loginData.captchaEnable) {
|
||||
await handleLogin({})
|
||||
} else {
|
||||
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行登录
|
||||
@@ -217,7 +220,7 @@ const getCode = async () => {
|
||||
}
|
||||
//获取租户ID
|
||||
const getTenantId = async () => {
|
||||
if (loginData.tenantEnable === 'true') {
|
||||
if (loginData.tenantEnable) {
|
||||
const res = await LoginApi.getTenantIdByName(loginData.loginForm.tenantName)
|
||||
authUtil.setTenantId(res)
|
||||
}
|
||||
@@ -274,10 +277,11 @@ const handleLogin = async (params) => {
|
||||
const code = route?.query?.code as string
|
||||
const state = route?.query?.state as string
|
||||
|
||||
const loginDataLoginForm = { ...loginData.loginForm }
|
||||
const res = await LoginApi.login({
|
||||
// 账号密码登录
|
||||
username: loginData.loginForm.username,
|
||||
password: loginData.loginForm.password,
|
||||
username: loginDataLoginForm.username,
|
||||
password: loginDataLoginForm.password,
|
||||
captchaVerification: params.captchaVerification,
|
||||
// 社交登录
|
||||
socialCode: code,
|
||||
@@ -292,8 +296,8 @@ const handleLogin = async (params) => {
|
||||
text: '正在加载系统中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
if (loginData.loginForm.rememberMe) {
|
||||
authUtil.setLoginForm(loginData.loginForm)
|
||||
if (loginDataLoginForm.rememberMe) {
|
||||
authUtil.setLoginForm(loginDataLoginForm)
|
||||
} else {
|
||||
authUtil.removeLoginForm()
|
||||
}
|
||||
|
||||
278
src/views/Login/components/ForgetPasswordForm.vue
Normal file
278
src/views/Login/components/ForgetPasswordForm.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<el-form
|
||||
v-show="getShow"
|
||||
ref="formSmsResetPassword"
|
||||
:model="resetPasswordData"
|
||||
:rules="rules"
|
||||
class="login-form"
|
||||
label-position="top"
|
||||
label-width="120px"
|
||||
size="large"
|
||||
>
|
||||
<el-row style="margin-right: -10px; margin-left: -10px">
|
||||
<!-- 租户名 -->
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item>
|
||||
<LoginFormTitle style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item v-if="resetPasswordData.tenantEnable === 'true'" prop="tenantName">
|
||||
<el-input
|
||||
v-model="resetPasswordData.tenantName"
|
||||
:placeholder="t('login.tenantNamePlaceholder')"
|
||||
:prefix-icon="iconHouse"
|
||||
type="primary"
|
||||
link
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<!-- 手机号 -->
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="mobile">
|
||||
<el-input
|
||||
v-model="resetPasswordData.mobile"
|
||||
:placeholder="t('login.mobileNumberPlaceholder')"
|
||||
:prefix-icon="iconCellphone"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<Verify
|
||||
ref="verify"
|
||||
:captchaType="captchaType"
|
||||
:imgSize="{ width: '400px', height: '200px' }"
|
||||
mode="pop"
|
||||
@success="getSmsCode"
|
||||
/>
|
||||
<!-- 验证码 -->
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="code">
|
||||
<el-row :gutter="5" justify="space-between" style="width: 100%">
|
||||
<el-col :span="24">
|
||||
<el-input
|
||||
v-model="resetPasswordData.code"
|
||||
:placeholder="t('login.codePlaceholder')"
|
||||
:prefix-icon="iconCircleCheck"
|
||||
>
|
||||
<template #append>
|
||||
<span
|
||||
v-if="mobileCodeTimer <= 0"
|
||||
class="getMobileCode"
|
||||
style="cursor: pointer"
|
||||
@click="getCode"
|
||||
>
|
||||
{{ t('login.getSmsCode') }}
|
||||
</span>
|
||||
<span v-if="mobileCodeTimer > 0" class="getMobileCode" style="cursor: pointer">
|
||||
{{ mobileCodeTimer }}秒后可重新获取
|
||||
</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<!-- </el-button> -->
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="password">
|
||||
<InputPassword
|
||||
v-model="resetPasswordData.password"
|
||||
:placeholder="t('login.passwordPlaceholder')"
|
||||
style="width: 100%"
|
||||
strength="true"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="check_password">
|
||||
<InputPassword
|
||||
v-model="resetPasswordData.check_password"
|
||||
:placeholder="t('login.checkPassword')"
|
||||
style="width: 100%"
|
||||
strength="true"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<!-- 登录按钮 / 返回按钮 -->
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item>
|
||||
<XButton
|
||||
:loading="loginLoading"
|
||||
:title="t('login.resetPassword')"
|
||||
class="w-[100%]"
|
||||
type="primary"
|
||||
@click="resetPassword()"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item>
|
||||
<XButton
|
||||
:loading="loginLoading"
|
||||
:title="t('login.backLogin')"
|
||||
class="w-[100%]"
|
||||
@click="handleBackLogin()"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
import { useIcon } from '@/hooks/web/useIcon'
|
||||
|
||||
import { sendSmsCode, smsResetPassword } from '@/api/login'
|
||||
import LoginFormTitle from './LoginFormTitle.vue'
|
||||
import { LoginStateEnum, useFormValid, useLoginState } from './useLogin'
|
||||
import { ElLoading } from 'element-plus'
|
||||
import * as authUtil from '@/utils/auth'
|
||||
import * as LoginApi from '@/api/login'
|
||||
defineOptions({ name: 'ForgetPasswordForm' })
|
||||
const verify = ref()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const { currentRoute, push } = useRouter()
|
||||
const formSmsResetPassword = ref()
|
||||
const loginLoading = ref(false)
|
||||
const iconHouse = useIcon({ icon: 'ep:house' })
|
||||
const iconCellphone = useIcon({ icon: 'ep:cellphone' })
|
||||
const iconCircleCheck = useIcon({ icon: 'ep:circle-check' })
|
||||
const { validForm } = useFormValid(formSmsResetPassword)
|
||||
const { handleBackLogin, getLoginState, setLoginState } = useLoginState()
|
||||
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.RESET_PASSWORD)
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
|
||||
|
||||
const validatePass2 = (rule, value, callback) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== resetPasswordData.password) {
|
||||
callback(new Error('两次输入密码不一致!'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
tenantName: [{ required: true, min: 2, max: 20, trigger: 'blur', message: '长度为4到16位' }],
|
||||
mobile: [{ required: true, min: 11, max: 11, trigger: 'blur', message: '手机号长度为11位' }],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
min: 4,
|
||||
max: 16,
|
||||
validator: validatePass2,
|
||||
trigger: 'blur',
|
||||
message: '密码长度为4到16位'
|
||||
}
|
||||
],
|
||||
check_password: [{ required: true, validator: validatePass2, trigger: 'blur' }],
|
||||
code: [required]
|
||||
}
|
||||
|
||||
const resetPasswordData = reactive({
|
||||
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
|
||||
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
|
||||
tenantName: '',
|
||||
username: '',
|
||||
password: '',
|
||||
check_password: '',
|
||||
mobile: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
const smsVO = reactive({
|
||||
tenantName: '',
|
||||
mobile: '',
|
||||
captchaVerification: '',
|
||||
scene: 23
|
||||
})
|
||||
const mobileCodeTimer = ref(0)
|
||||
const redirect = ref<string>('')
|
||||
|
||||
// 获取验证码
|
||||
const getCode = async () => {
|
||||
// 情况一,未开启:则直接发送验证码
|
||||
if (resetPasswordData.captchaEnable === 'false') {
|
||||
await getSmsCode({})
|
||||
} else {
|
||||
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行发送验证码
|
||||
// 弹出验证码
|
||||
verify.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
const getSmsCode = async (params) => {
|
||||
if (resetPasswordData.tenantEnable === 'true') {
|
||||
await getTenantId()
|
||||
}
|
||||
smsVO.captchaVerification = params.captchaVerification
|
||||
smsVO.mobile = resetPasswordData.mobile
|
||||
await sendSmsCode(smsVO).then(async () => {
|
||||
message.success(t('login.SmsSendMsg'))
|
||||
// 设置倒计时
|
||||
mobileCodeTimer.value = 60
|
||||
let msgTimer = setInterval(() => {
|
||||
mobileCodeTimer.value = mobileCodeTimer.value - 1
|
||||
if (mobileCodeTimer.value <= 0) {
|
||||
clearInterval(msgTimer)
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
watch(
|
||||
() => currentRoute.value,
|
||||
(route: RouteLocationNormalizedLoaded) => {
|
||||
redirect.value = route?.query?.redirect as string
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
const getTenantId = async () => {
|
||||
if (resetPasswordData.tenantEnable === 'true') {
|
||||
const res = await LoginApi.getTenantIdByName(resetPasswordData.tenantName)
|
||||
if (res == null) {
|
||||
message.error(t('login.invalidTenantName'))
|
||||
throw t('login.invalidTenantName')
|
||||
}
|
||||
authUtil.setTenantId(res)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
const resetPassword = async () => {
|
||||
const data = await validForm()
|
||||
if (!data) return
|
||||
await getTenantId()
|
||||
loginLoading.value = true
|
||||
await smsResetPassword(resetPasswordData)
|
||||
.then(async () => {
|
||||
message.success(t('login.resetPasswordSuccess'))
|
||||
setLoginState(LoginStateEnum.LOGIN)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
loginLoading.value = false
|
||||
setTimeout(() => {
|
||||
const loadingInstance = ElLoading.service()
|
||||
loadingInstance.close()
|
||||
}, 400)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.anticon) {
|
||||
&:hover {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.smsbtn {
|
||||
margin-top: 33px;
|
||||
}
|
||||
</style>
|
||||
@@ -59,7 +59,13 @@
|
||||
</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :offset="6" :span="12">
|
||||
<el-link style="float: right" type="primary">{{ t('login.forgetPassword') }}</el-link>
|
||||
<el-link
|
||||
style="float: right"
|
||||
type="primary"
|
||||
@click="setLoginState(LoginStateEnum.RESET_PASSWORD)"
|
||||
>
|
||||
{{ t('login.forgetPassword') }}
|
||||
</el-link>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
@@ -76,6 +82,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<Verify
|
||||
v-if="loginData.captchaEnable === 'true'"
|
||||
ref="verify"
|
||||
:captchaType="captchaType"
|
||||
:imgSize="{ width: '400px', height: '200px' }"
|
||||
@@ -184,11 +191,11 @@ const loginData = reactive({
|
||||
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
|
||||
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
|
||||
loginForm: {
|
||||
tenantName: '芋道源码',
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
|
||||
username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
|
||||
password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
|
||||
captchaVerification: '',
|
||||
rememberMe: false
|
||||
rememberMe: true // 默认记录我。如果不需要,可手动修改
|
||||
}
|
||||
})
|
||||
|
||||
@@ -218,14 +225,14 @@ const getTenantId = async () => {
|
||||
}
|
||||
}
|
||||
// 记住我
|
||||
const getCookie = () => {
|
||||
const getLoginFormCache = () => {
|
||||
const loginForm = authUtil.getLoginForm()
|
||||
if (loginForm) {
|
||||
loginData.loginForm = {
|
||||
...loginData.loginForm,
|
||||
username: loginForm.username ? loginForm.username : loginData.loginForm.username,
|
||||
password: loginForm.password ? loginForm.password : loginData.loginForm.password,
|
||||
rememberMe: loginForm.rememberMe ? true : false,
|
||||
rememberMe: loginForm.rememberMe,
|
||||
tenantName: loginForm.tenantName ? loginForm.tenantName : loginData.loginForm.tenantName
|
||||
}
|
||||
}
|
||||
@@ -241,7 +248,7 @@ const getTenantByWebsite = async () => {
|
||||
}
|
||||
const loading = ref() // ElLoading.service 返回的实例
|
||||
// 登录
|
||||
const handleLogin = async (params) => {
|
||||
const handleLogin = async (params: any) => {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
await getTenantId()
|
||||
@@ -249,8 +256,9 @@ const handleLogin = async (params) => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
loginData.loginForm.captchaVerification = params.captchaVerification
|
||||
const res = await LoginApi.login(loginData.loginForm)
|
||||
const loginDataLoginForm = { ...loginData.loginForm }
|
||||
loginDataLoginForm.captchaVerification = params.captchaVerification
|
||||
const res = await LoginApi.login(loginDataLoginForm)
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
@@ -259,8 +267,8 @@ const handleLogin = async (params) => {
|
||||
text: '正在加载系统中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
if (loginData.loginForm.rememberMe) {
|
||||
authUtil.setLoginForm(loginData.loginForm)
|
||||
if (loginDataLoginForm.rememberMe) {
|
||||
authUtil.setLoginForm(loginDataLoginForm)
|
||||
} else {
|
||||
authUtil.removeLoginForm()
|
||||
}
|
||||
@@ -272,7 +280,7 @@ const handleLogin = async (params) => {
|
||||
if (redirect.value.indexOf('sso') !== -1) {
|
||||
window.location.href = window.location.href.replace('/login?redirect=', '')
|
||||
} else {
|
||||
push({ path: redirect.value || permissionStore.addRouters[0].path })
|
||||
await push({ path: redirect.value || permissionStore.addRouters[0].path })
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
@@ -291,23 +299,28 @@ const doSocialLogin = async (type: number) => {
|
||||
await getTenantId()
|
||||
// 如果获取不到,则需要弹出提示,进行处理
|
||||
if (!authUtil.getTenantId()) {
|
||||
await message.prompt('请输入租户名称', t('common.reminder')).then(async ({ value }) => {
|
||||
const res = await LoginApi.getTenantIdByName(value)
|
||||
try {
|
||||
const data = await message.prompt('请输入租户名称', t('common.reminder'))
|
||||
if (data?.action !== 'confirm') throw 'cancel'
|
||||
const res = await LoginApi.getTenantIdByName(data.value)
|
||||
authUtil.setTenantId(res)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error === 'cancel') return
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// 计算 redirectUri
|
||||
// tricky: type、redirect需要先encode一次,否则钉钉回调会丢失。
|
||||
// 配合 Login/SocialLogin.vue#getUrlValue() 使用
|
||||
// 注意: type、redirect 需要先 encode 一次,否则钉钉回调会丢失。
|
||||
// 配合 social-login.vue#getUrlValue() 使用
|
||||
const redirectUri =
|
||||
location.origin +
|
||||
'/social-login?' +
|
||||
encodeURIComponent(`type=${type}&redirect=${redirect.value || '/'}`)
|
||||
|
||||
// 进行跳转
|
||||
const res = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri))
|
||||
window.location.href = res
|
||||
window.location.href = await LoginApi.socialAuthRedirect(type, encodeURIComponent(redirectUri))
|
||||
}
|
||||
}
|
||||
watch(
|
||||
@@ -320,7 +333,7 @@ watch(
|
||||
}
|
||||
)
|
||||
onMounted(() => {
|
||||
getCookie()
|
||||
getLoginFormCache()
|
||||
getTenantByWebsite()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<el-row v-show="getShow" style="margin-right: -10px; margin-left: -10px">
|
||||
<el-row v-show="getShow" class="login-form" style="margin-right: -10px; margin-left: -10px">
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<LoginFormTitle style="width: 100%" />
|
||||
</el-col>
|
||||
|
||||
@@ -1,142 +1,280 @@
|
||||
<template>
|
||||
<Form
|
||||
<el-form
|
||||
v-show="getShow"
|
||||
:rules="rules"
|
||||
:schema="schema"
|
||||
class="dark:(border-1 border-[var(--el-border-color)] border-solid)"
|
||||
hide-required-asterisk
|
||||
ref="formLogin"
|
||||
:model="registerData.registerForm"
|
||||
:rules="registerRules"
|
||||
class="login-form"
|
||||
label-position="top"
|
||||
label-width="120px"
|
||||
size="large"
|
||||
@register="register"
|
||||
>
|
||||
<template #title>
|
||||
<LoginFormTitle style="width: 100%" />
|
||||
</template>
|
||||
|
||||
<template #code="form">
|
||||
<div class="w-[100%] flex">
|
||||
<el-input v-model="form['code']" :placeholder="t('login.codePlaceholder')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #register>
|
||||
<div class="w-[100%]">
|
||||
<XButton
|
||||
:loading="loading"
|
||||
:title="t('login.register')"
|
||||
class="w-[100%]"
|
||||
type="primary"
|
||||
@click="loginRegister()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-15px w-[100%]">
|
||||
<XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" />
|
||||
</div>
|
||||
</template>
|
||||
</Form>
|
||||
<el-row style="margin-right: -10px; margin-left: -10px">
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item>
|
||||
<LoginFormTitle style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item v-if="registerData.tenantEnable === 'true'" prop="tenantName">
|
||||
<el-input
|
||||
v-model="registerData.registerForm.tenantName"
|
||||
:placeholder="t('login.tenantname')"
|
||||
:prefix-icon="iconHouse"
|
||||
link
|
||||
type="primary"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerData.registerForm.username"
|
||||
:placeholder="t('login.username')"
|
||||
size="large"
|
||||
:prefix-icon="iconAvatar"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerData.registerForm.nickname"
|
||||
placeholder="昵称"
|
||||
size="large"
|
||||
:prefix-icon="iconAvatar"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerData.registerForm.password"
|
||||
type="password"
|
||||
auto-complete="off"
|
||||
:placeholder="t('login.password')"
|
||||
size="large"
|
||||
:prefix-icon="iconLock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerData.registerForm.confirmPassword"
|
||||
type="password"
|
||||
size="large"
|
||||
auto-complete="off"
|
||||
:placeholder="t('login.checkPassword')"
|
||||
:prefix-icon="iconLock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" style="padding-right: 10px; padding-left: 10px">
|
||||
<el-form-item>
|
||||
<XButton
|
||||
:loading="loginLoading"
|
||||
:title="t('login.register')"
|
||||
class="w-[100%]"
|
||||
type="primary"
|
||||
@click="getCode()"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<Verify
|
||||
v-if="registerData.captchaEnable === 'true'"
|
||||
ref="verify"
|
||||
:captchaType="captchaType"
|
||||
:imgSize="{ width: '400px', height: '200px' }"
|
||||
mode="pop"
|
||||
@success="handleRegister"
|
||||
/>
|
||||
</el-row>
|
||||
<XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" />
|
||||
</el-form>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { FormRules } from 'element-plus'
|
||||
|
||||
import { useForm } from '@/hooks/web/useForm'
|
||||
import { useValidator } from '@/hooks/web/useValidator'
|
||||
import { ElLoading } from 'element-plus'
|
||||
import LoginFormTitle from './LoginFormTitle.vue'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { useIcon } from '@/hooks/web/useIcon'
|
||||
import * as authUtil from '@/utils/auth'
|
||||
import { usePermissionStore } from '@/store/modules/permission'
|
||||
import * as LoginApi from '@/api/login'
|
||||
import { LoginStateEnum, useLoginState } from './useLogin'
|
||||
import { FormSchema } from '@/types/form'
|
||||
|
||||
defineOptions({ name: 'RegisterForm' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { required } = useValidator()
|
||||
const { register, elFormRef } = useForm()
|
||||
const iconHouse = useIcon({ icon: 'ep:house' })
|
||||
const iconAvatar = useIcon({ icon: 'ep:avatar' })
|
||||
const iconLock = useIcon({ icon: 'ep:lock' })
|
||||
const formLogin = ref()
|
||||
const { handleBackLogin, getLoginState } = useLoginState()
|
||||
const { currentRoute, push } = useRouter()
|
||||
const permissionStore = usePermissionStore()
|
||||
const redirect = ref<string>('')
|
||||
const loginLoading = ref(false)
|
||||
const verify = ref()
|
||||
const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
|
||||
|
||||
const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER)
|
||||
|
||||
const schema = reactive<FormSchema[]>([
|
||||
{
|
||||
field: 'title',
|
||||
colProps: {
|
||||
span: 24
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
label: t('login.username'),
|
||||
value: '',
|
||||
component: 'Input',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
placeholder: t('login.usernamePlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
label: t('login.password'),
|
||||
value: '',
|
||||
component: 'InputPassword',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
style: {
|
||||
width: '100%'
|
||||
},
|
||||
strength: true,
|
||||
placeholder: t('login.passwordPlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'check_password',
|
||||
label: t('login.checkPassword'),
|
||||
value: '',
|
||||
component: 'InputPassword',
|
||||
colProps: {
|
||||
span: 24
|
||||
},
|
||||
componentProps: {
|
||||
style: {
|
||||
width: '100%'
|
||||
},
|
||||
strength: true,
|
||||
placeholder: t('login.passwordPlaceholder')
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'code',
|
||||
label: t('login.code'),
|
||||
colProps: {
|
||||
span: 24
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'register',
|
||||
colProps: {
|
||||
span: 24
|
||||
}
|
||||
const equalToPassword = (rule, value, callback) => {
|
||||
if (registerData.registerForm.password !== value) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
])
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [required()],
|
||||
password: [required()],
|
||||
check_password: [required()],
|
||||
code: [required()]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const registerRules = {
|
||||
tenantName: [
|
||||
{ required: true, trigger: 'blur', message: '请输入您所属的租户' },
|
||||
{ min: 2, max: 20, message: '租户账号长度必须介于 2 和 20 之间', trigger: 'blur' }
|
||||
],
|
||||
username: [
|
||||
{ required: true, trigger: 'blur', message: '请输入您的账号' },
|
||||
{ min: 4, max: 30, message: '用户账号长度必须介于 4 和 30 之间', trigger: 'blur' }
|
||||
],
|
||||
nickname: [
|
||||
{ required: true, trigger: 'blur', message: '请输入您的昵称' },
|
||||
{ min: 0, max: 30, message: '昵称长度必须介于 0 和 30 之间', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, trigger: 'blur', message: '请输入您的密码' },
|
||||
{ min: 5, max: 20, message: '用户密码长度必须介于 5 和 20 之间', trigger: 'blur' },
|
||||
{ pattern: /^[^<>"'|\\]+$/, message: '不能包含非法字符:< > " \' \\\ |', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, trigger: 'blur', message: '请再次输入您的密码' },
|
||||
{ required: true, validator: equalToPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const loginRegister = async () => {
|
||||
const formRef = unref(elFormRef)
|
||||
formRef?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
loading.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const registerData = reactive({
|
||||
isShowPassword: false,
|
||||
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
|
||||
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
|
||||
registerForm: {
|
||||
tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
|
||||
nickname: '',
|
||||
tenantId: 0,
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
captchaVerification: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 提交注册
|
||||
const handleRegister = async (params: any) => {
|
||||
loading.value = true
|
||||
try {
|
||||
if (registerData.tenantEnable) {
|
||||
await getTenantId()
|
||||
registerData.registerForm.tenantId = authUtil.getTenantId()
|
||||
}
|
||||
})
|
||||
|
||||
if (registerData.captchaEnable) {
|
||||
registerData.registerForm.captchaVerification = params.captchaVerification
|
||||
}
|
||||
|
||||
const res = await LoginApi.register(registerData.registerForm)
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
loading.value = ElLoading.service({
|
||||
lock: true,
|
||||
text: '正在加载系统中...',
|
||||
background: 'rgba(0, 0, 0, 0.7)'
|
||||
})
|
||||
|
||||
authUtil.removeLoginForm()
|
||||
|
||||
authUtil.setToken(res)
|
||||
if (!redirect.value) {
|
||||
redirect.value = '/'
|
||||
}
|
||||
// 判断是否为SSO登录
|
||||
if (redirect.value.indexOf('sso') !== -1) {
|
||||
window.location.href = window.location.href.replace('/login?redirect=', '')
|
||||
} else {
|
||||
push({ path: redirect.value || permissionStore.addRouters[0].path })
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
loading.value.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
const getCode = async () => {
|
||||
// 情况一,未开启:则直接注册
|
||||
if (registerData.captchaEnable === 'false') {
|
||||
await handleRegister({})
|
||||
} else {
|
||||
// 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行注册
|
||||
// 弹出验证码
|
||||
verify.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取租户 ID
|
||||
const getTenantId = async () => {
|
||||
if (registerData.tenantEnable === 'true') {
|
||||
const res = await LoginApi.getTenantIdByName(registerData.registerForm.tenantName)
|
||||
authUtil.setTenantId(res)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据域名,获得租户信息
|
||||
const getTenantByWebsite = async () => {
|
||||
const website = location.host
|
||||
const res = await LoginApi.getTenantByWebsite(website)
|
||||
if (res) {
|
||||
registerData.registerForm.tenantName = res.name
|
||||
authUtil.setTenantId(res.id)
|
||||
}
|
||||
}
|
||||
const loading = ref() // ElLoading.service 返回的实例
|
||||
|
||||
watch(
|
||||
() => currentRoute.value,
|
||||
(route: RouteLocationNormalizedLoaded) => {
|
||||
redirect.value = route?.query?.redirect as string
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
onMounted(() => {
|
||||
// getCookie()
|
||||
getTenantByWebsite()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.anticon) {
|
||||
&:hover {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-code {
|
||||
float: right;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100px;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-checkbox
|
||||
v-for="scope in queryParams.scopes"
|
||||
:key="scope"
|
||||
:label="scope"
|
||||
:value="scope"
|
||||
style="display: block; margin-bottom: -10px"
|
||||
>
|
||||
{{ formatScope(scope) }}
|
||||
|
||||
@@ -4,5 +4,6 @@ import LoginFormTitle from './LoginFormTitle.vue'
|
||||
import RegisterForm from './RegisterForm.vue'
|
||||
import QrCodeForm from './QrCodeForm.vue'
|
||||
import SSOLoginVue from './SSOLogin.vue'
|
||||
import ForgetPasswordForm from './ForgetPasswordForm.vue'
|
||||
|
||||
export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue }
|
||||
export { LoginForm, MobileForm, LoginFormTitle, RegisterForm, QrCodeForm, SSOLoginVue, ForgetPasswordForm }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<!-- TODO @芋艿:可优化,对标 vben 版本 -->
|
||||
<div class="flex">
|
||||
<el-card class="user w-1/3" shadow="hover">
|
||||
<template #header>
|
||||
@@ -6,18 +7,13 @@
|
||||
<span>{{ t('profile.user.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ProfileUser />
|
||||
<ProfileUser ref="profileUserRef" />
|
||||
</el-card>
|
||||
<el-card class="user ml-3 w-2/3" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ t('profile.info.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<el-tabs v-model="activeName" class="profile-tabs" style="height: 400px" tab-position="top">
|
||||
<el-tab-pane :label="t('profile.info.basicInfo')" name="basicInfo">
|
||||
<BasicInfo />
|
||||
<BasicInfo @success="handleBasicInfoSuccess" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="t('profile.info.resetPwd')" name="resetPwd">
|
||||
<ResetPwd />
|
||||
@@ -36,6 +32,12 @@ import { BasicInfo, ProfileUser, ResetPwd, UserSocial } from './components'
|
||||
const { t } = useI18n()
|
||||
defineOptions({ name: 'Profile' })
|
||||
const activeName = ref('basicInfo')
|
||||
const profileUserRef = ref()
|
||||
|
||||
// 处理基本信息更新成功
|
||||
const handleBasicInfoSuccess = async () => {
|
||||
await profileUserRef.value?.refresh()
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.user {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<Form ref="formRef" :labelWidth="200" :rules="rules" :schema="schema">
|
||||
<template #sex="form">
|
||||
<el-radio-group v-model="form['sex']">
|
||||
<el-radio :label="1">{{ t('profile.user.man') }}</el-radio>
|
||||
<el-radio :label="2">{{ t('profile.user.woman') }}</el-radio>
|
||||
<el-radio :value="1">{{ t('profile.user.man') }}</el-radio>
|
||||
<el-radio :value="2">{{ t('profile.user.woman') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
</Form>
|
||||
@@ -21,11 +21,19 @@ import {
|
||||
updateUserProfile,
|
||||
UserProfileUpdateReqVO
|
||||
} from '@/api/system/user/profile'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
defineOptions({ name: 'BasicInfo' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage() // 消息弹窗
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
// 表单校验
|
||||
const rules = reactive<FormRules>({
|
||||
nickname: [{ required: true, message: t('profile.rules.nickname'), trigger: 'blur' }],
|
||||
@@ -78,13 +86,17 @@ const submit = () => {
|
||||
const data = unref(formRef)?.formModel as UserProfileUpdateReqVO
|
||||
await updateUserProfile(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
await init()
|
||||
const profile = await init()
|
||||
userStore.setUserNicknameAction(profile.nickname)
|
||||
// 发送成功事件
|
||||
emit('success')
|
||||
}
|
||||
})
|
||||
}
|
||||
const init = async () => {
|
||||
const res = await getUserProfile()
|
||||
unref(formRef)?.setValues(res)
|
||||
return res
|
||||
}
|
||||
onMounted(async () => {
|
||||
await init()
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<li class="list-group-item">
|
||||
<Icon class="mr-5px" icon="ep:calendar" />
|
||||
{{ t('profile.user.createTime') }}
|
||||
<div class="pull-right">{{ formatDate(userInfo?.createTime) }}</div>
|
||||
<div class="pull-right">{{ formatDate(userInfo.createTime) }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -55,11 +55,17 @@ import { getUserProfile, ProfileVO } from '@/api/system/user/profile'
|
||||
defineOptions({ name: 'ProfileUser' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const userInfo = ref<ProfileVO>()
|
||||
const userInfo = ref({} as ProfileVO)
|
||||
const getUserInfo = async () => {
|
||||
const users = await getUserProfile()
|
||||
userInfo.value = users
|
||||
}
|
||||
|
||||
// 暴露刷新方法
|
||||
defineExpose({
|
||||
refresh: getUserInfo
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await getUserInfo()
|
||||
})
|
||||
|
||||
@@ -12,19 +12,33 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { propTypes } from '@/utils/propTypes'
|
||||
import { uploadAvatar } from '@/api/system/user/profile'
|
||||
import { updateUserProfile } from '@/api/system/user/profile'
|
||||
import { CropperAvatar } from '@/components/Cropper'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useUpload } from '@/components/UploadFile/src/useUpload'
|
||||
import { UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
|
||||
|
||||
// TODO @芋艿:合并到 ProfileUser 组件中,更简洁一点
|
||||
defineOptions({ name: 'UserAvatar' })
|
||||
|
||||
defineProps({
|
||||
img: propTypes.string.def('')
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const cropperRef = ref()
|
||||
const handelUpload = async ({ data }) => {
|
||||
await uploadAvatar({ avatarFile: data })
|
||||
const { httpRequest } = useUpload()
|
||||
const avatar = ((await httpRequest({
|
||||
file: data,
|
||||
filename: 'avatar.png',
|
||||
} as UploadRequestOptions)) as unknown as { data: string }).data
|
||||
await updateUserProfile({ avatar })
|
||||
|
||||
// 关闭弹窗,并更新 userStore
|
||||
cropperRef.value.close()
|
||||
await userStore.setUserAvatarAction(avatar)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<!-- AI 对话 -->
|
||||
<template>
|
||||
<el-aside width="260px" class="conversation-container h-100%">
|
||||
<!-- 左顶部:对话 -->
|
||||
<div class="h-100%">
|
||||
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
|
||||
<Icon icon="ep:plus" class="mr-5px" />
|
||||
新建对话
|
||||
</el-button>
|
||||
|
||||
<!-- 左顶部:搜索对话 -->
|
||||
<el-input
|
||||
v-model="searchName"
|
||||
size="large"
|
||||
class="mt-10px search-input"
|
||||
placeholder="搜索历史记录"
|
||||
@keyup="searchConversation"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="ep:search" />
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<!-- 左中间:对话列表 -->
|
||||
<div class="conversation-list">
|
||||
<!-- 情况一:加载中 -->
|
||||
<el-empty v-if="loading" description="." :v-loading="loading" />
|
||||
<!-- 情况二:按照 group 分组,展示聊天会话 list 列表 -->
|
||||
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
|
||||
<div
|
||||
class="conversation-item classify-title"
|
||||
v-if="conversationMap[conversationKey].length"
|
||||
>
|
||||
<el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
|
||||
</div>
|
||||
<div
|
||||
class="conversation-item"
|
||||
v-for="conversation in conversationMap[conversationKey]"
|
||||
:key="conversation.id"
|
||||
@click="handleConversationClick(conversation.id)"
|
||||
@mouseover="hoverConversationId = conversation.id"
|
||||
@mouseout="hoverConversationId = ''"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
conversation.id === activeConversationId ? 'conversation active' : 'conversation'
|
||||
"
|
||||
>
|
||||
<div class="title-wrapper">
|
||||
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
|
||||
<span class="title">{{ conversation.title }}</span>
|
||||
</div>
|
||||
<div class="button-wrapper" v-show="hoverConversationId === conversation.id">
|
||||
<el-button class="btn" link @click.stop="handleTop(conversation)">
|
||||
<el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
|
||||
<el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
|
||||
</el-button>
|
||||
<el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
|
||||
<el-icon title="编辑">
|
||||
<Icon icon="ep:edit" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
|
||||
<el-icon title="删除对话">
|
||||
<Icon icon="ep:delete" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 底部占位 -->
|
||||
<div class="h-160px w-100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左底部:工具栏 -->
|
||||
<div class="tool-box">
|
||||
<div @click="handleRoleRepository">
|
||||
<Icon icon="ep:user" />
|
||||
<el-text size="small">角色仓库</el-text>
|
||||
</div>
|
||||
<div @click="handleClearConversation">
|
||||
<Icon icon="ep:delete" />
|
||||
<el-text size="small">清空未置顶对话</el-text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 角色仓库抽屉 -->
|
||||
<el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px">
|
||||
<RoleRepository />
|
||||
</el-drawer>
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||
import RoleRepository from '../role/RoleRepository.vue'
|
||||
import { Bottom, Top } from '@element-plus/icons-vue'
|
||||
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
// 定义属性
|
||||
const searchName = ref<string>('') // 对话搜索
|
||||
const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null
|
||||
const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话
|
||||
const conversationList = ref([] as ChatConversationVO[]) // 对话列表
|
||||
const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
|
||||
const loading = ref<boolean>(false) // 加载中
|
||||
const loadingTime = ref<any>() // 加载中定时器
|
||||
|
||||
// 定义组件 props
|
||||
const props = defineProps({
|
||||
activeId: {
|
||||
type: String || null,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 定义钩子
|
||||
const emits = defineEmits([
|
||||
'onConversationCreate',
|
||||
'onConversationClick',
|
||||
'onConversationClear',
|
||||
'onConversationDelete'
|
||||
])
|
||||
|
||||
/** 搜索对话 */
|
||||
const searchConversation = async (e) => {
|
||||
// 恢复数据
|
||||
if (!searchName.value.trim().length) {
|
||||
conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
|
||||
} else {
|
||||
// 过滤
|
||||
const filterValues = conversationList.value.filter((item) => {
|
||||
return item.title.includes(searchName.value.trim())
|
||||
})
|
||||
conversationMap.value = await getConversationGroupByCreateTime(filterValues)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击对话 */
|
||||
const handleConversationClick = async (id: number) => {
|
||||
// 过滤出选中的对话
|
||||
const filterConversation = conversationList.value.filter((item) => {
|
||||
return item.id === id
|
||||
})
|
||||
// 回调 onConversationClick
|
||||
// noinspection JSVoidFunctionReturnValueUsed
|
||||
const success = emits('onConversationClick', filterConversation[0])
|
||||
// 切换对话
|
||||
if (success) {
|
||||
activeConversationId.value = id
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取对话列表 */
|
||||
const getChatConversationList = async () => {
|
||||
try {
|
||||
// 加载中
|
||||
loadingTime.value = setTimeout(() => {
|
||||
loading.value = true
|
||||
}, 50)
|
||||
|
||||
// 1.1 获取 对话数据
|
||||
conversationList.value = await ChatConversationApi.getChatConversationMyList()
|
||||
// 1.2 排序
|
||||
conversationList.value.sort((a, b) => {
|
||||
return b.createTime - a.createTime
|
||||
})
|
||||
// 1.3 没有任何对话情况
|
||||
if (conversationList.value.length === 0) {
|
||||
activeConversationId.value = null
|
||||
conversationMap.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
|
||||
conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
|
||||
} finally {
|
||||
// 清理定时器
|
||||
if (loadingTime.value) {
|
||||
clearTimeout(loadingTime.value)
|
||||
}
|
||||
// 加载完成
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 按照 creteTime 创建时间,进行分组 */
|
||||
const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => {
|
||||
// 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
|
||||
// noinspection NonAsciiCharacters
|
||||
const groupMap = {
|
||||
置顶: [],
|
||||
今天: [],
|
||||
一天前: [],
|
||||
三天前: [],
|
||||
七天前: [],
|
||||
三十天前: []
|
||||
}
|
||||
// 当前时间的时间戳
|
||||
const now = Date.now()
|
||||
// 定义时间间隔常量(单位:毫秒)
|
||||
const oneDay = 24 * 60 * 60 * 1000
|
||||
const threeDays = 3 * oneDay
|
||||
const sevenDays = 7 * oneDay
|
||||
const thirtyDays = 30 * oneDay
|
||||
for (const conversation of list) {
|
||||
// 置顶
|
||||
if (conversation.pinned) {
|
||||
groupMap['置顶'].push(conversation)
|
||||
continue
|
||||
}
|
||||
// 计算时间差(单位:毫秒)
|
||||
const diff = now - conversation.createTime
|
||||
// 根据时间间隔判断
|
||||
if (diff < oneDay) {
|
||||
groupMap['今天'].push(conversation)
|
||||
} else if (diff < threeDays) {
|
||||
groupMap['一天前'].push(conversation)
|
||||
} else if (diff < sevenDays) {
|
||||
groupMap['三天前'].push(conversation)
|
||||
} else if (diff < thirtyDays) {
|
||||
groupMap['七天前'].push(conversation)
|
||||
} else {
|
||||
groupMap['三十天前'].push(conversation)
|
||||
}
|
||||
}
|
||||
return groupMap
|
||||
}
|
||||
|
||||
/** 新建对话 */
|
||||
const createConversation = async () => {
|
||||
// 1. 新建对话
|
||||
const conversationId = await ChatConversationApi.createChatConversationMy(
|
||||
{} as unknown as ChatConversationVO
|
||||
)
|
||||
// 2. 获取对话内容
|
||||
await getChatConversationList()
|
||||
// 3. 选中对话
|
||||
await handleConversationClick(conversationId)
|
||||
// 4. 回调
|
||||
emits('onConversationCreate')
|
||||
}
|
||||
|
||||
/** 修改对话的标题 */
|
||||
const updateConversationTitle = async (conversation: ChatConversationVO) => {
|
||||
// 1. 二次确认
|
||||
const { value } = await ElMessageBox.prompt('修改标题', {
|
||||
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
|
||||
inputErrorMessage: '标题不能为空',
|
||||
inputValue: conversation.title
|
||||
})
|
||||
// 2. 发起修改
|
||||
await ChatConversationApi.updateChatConversationMy({
|
||||
id: conversation.id,
|
||||
title: value
|
||||
} as ChatConversationVO)
|
||||
message.success('重命名成功')
|
||||
// 3. 刷新列表
|
||||
await getChatConversationList()
|
||||
// 4. 过滤当前切换的
|
||||
const filterConversationList = conversationList.value.filter((item) => {
|
||||
return item.id === conversation.id
|
||||
})
|
||||
if (filterConversationList.length > 0) {
|
||||
// tip:避免切换对话
|
||||
if (activeConversationId.value === filterConversationList[0].id) {
|
||||
emits('onConversationClick', filterConversationList[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除聊天对话 */
|
||||
const deleteChatConversation = async (conversation: ChatConversationVO) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`)
|
||||
// 发起删除
|
||||
await ChatConversationApi.deleteChatConversationMy(conversation.id)
|
||||
message.success('对话已删除')
|
||||
// 刷新列表
|
||||
await getChatConversationList()
|
||||
// 回调
|
||||
emits('onConversationDelete', conversation)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 清空对话 */
|
||||
const handleClearConversation = async () => {
|
||||
try {
|
||||
await message.confirm('确认后对话会全部清空,置顶的对话除外。')
|
||||
await ChatConversationApi.deleteChatConversationMyByUnpinned()
|
||||
ElMessage({
|
||||
message: '操作成功!',
|
||||
type: 'success'
|
||||
})
|
||||
// 清空 对话 和 对话内容
|
||||
activeConversationId.value = null
|
||||
// 获取 对话列表
|
||||
await getChatConversationList()
|
||||
// 回调 方法
|
||||
emits('onConversationClear')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 对话置顶 */
|
||||
const handleTop = async (conversation: ChatConversationVO) => {
|
||||
// 更新对话置顶
|
||||
conversation.pinned = !conversation.pinned
|
||||
await ChatConversationApi.updateChatConversationMy(conversation)
|
||||
// 刷新对话
|
||||
await getChatConversationList()
|
||||
}
|
||||
|
||||
// ============ 角色仓库 ============
|
||||
|
||||
/** 角色仓库抽屉 */
|
||||
const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开
|
||||
const handleRoleRepository = async () => {
|
||||
roleRepositoryOpen.value = !roleRepositoryOpen.value
|
||||
}
|
||||
|
||||
/** 监听选中的对话 */
|
||||
const { activeId } = toRefs(props)
|
||||
watch(activeId, async (newValue, oldValue) => {
|
||||
activeConversationId.value = newValue as string
|
||||
})
|
||||
|
||||
// 定义 public 方法
|
||||
defineExpose({ createConversation })
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 获取 对话列表
|
||||
await getChatConversationList()
|
||||
// 默认选中
|
||||
if (props.activeId) {
|
||||
activeConversationId.value = props.activeId
|
||||
} else {
|
||||
// 首次默认选中第一个
|
||||
if (conversationList.value.length) {
|
||||
activeConversationId.value = conversationList.value[0].id
|
||||
// 回调 onConversationClick
|
||||
await emits('onConversationClick', conversationList.value[0])
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversation-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px 0;
|
||||
overflow: hidden;
|
||||
|
||||
.btn-new-conversation {
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
|
||||
.classify-title {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding: 0 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
line-height: 30px;
|
||||
|
||||
&.active {
|
||||
background-color: #e6e6e6;
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 2px 10px;
|
||||
max-width: 220px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: rgba(0, 0, 0, 0.77);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
// 对话编辑、删除
|
||||
.button-wrapper {
|
||||
right: 2px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-items: center;
|
||||
color: #606266;
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 角色仓库、清空未设置对话
|
||||
.tool-box {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
//width: 100%;
|
||||
padding: 0 20px;
|
||||
background-color: #f4f4f4;
|
||||
box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8);
|
||||
line-height: 35px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--el-text-color);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #606266;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
> span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<Dialog title="设定" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="130px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="角色设定" prop="systemMessage">
|
||||
<el-input
|
||||
type="textarea"
|
||||
v-model="formData.systemMessage"
|
||||
:rows="4"
|
||||
placeholder="请输入角色设定"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型" prop="modelId">
|
||||
<el-select v-model="formData.modelId" placeholder="请选择模型">
|
||||
<el-option
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:label="model.name"
|
||||
:value="model.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="温度参数" prop="temperature">
|
||||
<el-input-number
|
||||
v-model="formData.temperature"
|
||||
placeholder="请输入温度参数"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:precision="2"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="回复数 Token 数" prop="maxTokens">
|
||||
<el-input-number
|
||||
v-model="formData.maxTokens"
|
||||
placeholder="请输入回复数 Token 数"
|
||||
:min="0"
|
||||
:max="8192"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上下文数量" prop="maxContexts">
|
||||
<el-input-number
|
||||
v-model="formData.maxContexts"
|
||||
placeholder="请输入上下文数量"
|
||||
:min="0"
|
||||
:max="20"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||
import { AiModelTypeEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
/** AI 聊天对话的更新表单 */
|
||||
defineOptions({ name: 'ChatConversationUpdateForm' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
systemMessage: undefined,
|
||||
modelId: undefined,
|
||||
temperature: undefined,
|
||||
maxTokens: undefined,
|
||||
maxContexts: undefined
|
||||
})
|
||||
const formRules = reactive({
|
||||
modelId: [{ required: true, message: '模型不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
|
||||
temperature: [{ required: true, message: '温度参数不能为空', trigger: 'blur' }],
|
||||
maxTokens: [{ required: true, message: '回复数 Token 数不能为空', trigger: 'blur' }],
|
||||
maxContexts: [{ required: true, message: '上下文数量不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const models = ref([] as ModelVO[]) // 聊天模型列表
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (id: number) => {
|
||||
dialogVisible.value = true
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = await ChatConversationApi.getChatConversationMy(id)
|
||||
formData.value = Object.keys(formData.value).reduce((obj, key) => {
|
||||
if (data.hasOwnProperty(key)) {
|
||||
obj[key] = data[key]
|
||||
}
|
||||
return obj
|
||||
}, {})
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
// 获得下拉数据
|
||||
models.value = await ModelApi.getModelSimpleList(AiModelTypeEnum.CHAT)
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ChatConversationVO
|
||||
await ChatConversationApi.updateChatConversationMy(data)
|
||||
message.success('对话配置已更新')
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
systemMessage: undefined,
|
||||
modelId: undefined,
|
||||
temperature: undefined,
|
||||
maxTokens: undefined,
|
||||
maxContexts: undefined
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
104
src/views/ai/chat/index/components/message/MessageKnowledge.vue
Normal file
104
src/views/ai/chat/index/components/message/MessageKnowledge.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<!-- 知识引用组件 -->
|
||||
<template>
|
||||
<!-- 知识引用列表 -->
|
||||
<div v-if="segments && segments.length > 0" class="mt-10px p-10px rounded-8px bg-[#f5f5f5]">
|
||||
<div class="text-14px text-[#666] mb-8px flex items-center">
|
||||
<Icon icon="ep:document" class="mr-5px" /> 知识引用
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-8px">
|
||||
<div
|
||||
v-for="(doc, index) in documentList"
|
||||
:key="index"
|
||||
class="p-8px px-12px bg-white rounded-6px cursor-pointer transition-all hover:bg-[#e6f4ff]"
|
||||
@click="handleClick(doc)"
|
||||
>
|
||||
<div class="text-14px text-[#333] mb-4px">
|
||||
{{ doc.title }}
|
||||
<span class="text-12px text-[#999] ml-4px">({{ doc.segments.length }} 条)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 知识引用详情弹窗 -->
|
||||
<el-popover
|
||||
v-model:visible="dialogVisible"
|
||||
:width="600"
|
||||
trigger="click"
|
||||
placement="top-start"
|
||||
:offset="55"
|
||||
popper-class="knowledge-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<div ref="documentRef"></div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="text-16px font-bold mb-12px">{{ document?.title }}</div>
|
||||
<div class="max-h-[60vh] overflow-y-auto">
|
||||
<div
|
||||
v-for="(segment, index) in document?.segments"
|
||||
:key="index"
|
||||
class="p-12px border-b-solid border-b-[#eee] last:border-b-0"
|
||||
>
|
||||
<div
|
||||
class="block mb-8px px-8px py-2px bg-[#f5f5f5] rounded-4px text-12px text-[#666] w-fit"
|
||||
>
|
||||
分段 {{ segment.id }}
|
||||
</div>
|
||||
<div class="text-14px leading-[1.6] text-[#333] mt-[10px]">
|
||||
{{ segment.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
segments: {
|
||||
id: number
|
||||
documentId: number
|
||||
documentName: string
|
||||
content: string
|
||||
}[]
|
||||
}>()
|
||||
|
||||
const document = ref<{
|
||||
id: number
|
||||
title: string
|
||||
segments: {
|
||||
id: number
|
||||
content: string
|
||||
}[]
|
||||
} | null>(null) // 知识库文档列表
|
||||
const dialogVisible = ref(false) // 知识引用详情弹窗
|
||||
const documentRef = ref<HTMLElement>() // 知识引用详情弹窗 Ref
|
||||
|
||||
/** 按照 document 聚合 segments */
|
||||
const documentList = computed(() => {
|
||||
if (!props.segments) return []
|
||||
|
||||
const docMap = new Map()
|
||||
props.segments.forEach((segment) => {
|
||||
if (!docMap.has(segment.documentId)) {
|
||||
docMap.set(segment.documentId, {
|
||||
id: segment.documentId,
|
||||
title: segment.documentName,
|
||||
segments: []
|
||||
})
|
||||
}
|
||||
docMap.get(segment.documentId).segments.push({
|
||||
id: segment.id,
|
||||
content: segment.content
|
||||
})
|
||||
})
|
||||
return Array.from(docMap.values())
|
||||
})
|
||||
|
||||
/** 点击 document 处理 */
|
||||
const handleClick = (doc: any) => {
|
||||
document.value = doc
|
||||
dialogVisible.value = true
|
||||
}
|
||||
</script>
|
||||
284
src/views/ai/chat/index/components/message/MessageList.vue
Normal file
284
src/views/ai/chat/index/components/message/MessageList.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div ref="messageContainer" class="h-100% overflow-y-auto relative">
|
||||
<div class="chat-list" v-for="(item, index) in list" :key="index">
|
||||
<!-- 靠左 message:system、assistant 类型 -->
|
||||
<div class="left-message message-item" v-if="item.type !== 'user'">
|
||||
<div class="avatar">
|
||||
<el-avatar :src="roleAvatar" />
|
||||
</div>
|
||||
<div class="message">
|
||||
<div>
|
||||
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
|
||||
</div>
|
||||
<div class="left-text-container" ref="markdownViewRef">
|
||||
<MarkdownView class="left-text" :content="item.content" />
|
||||
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
|
||||
</div>
|
||||
<div class="left-btns">
|
||||
<el-button class="btn-cus" link @click="copyContent(item.content)">
|
||||
<img class="btn-image" src="@/assets/ai/copy.svg" />
|
||||
</el-button>
|
||||
<el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)">
|
||||
<img class="btn-image h-17px" src="@/assets/ai/delete.svg" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 靠右 message:user 类型 -->
|
||||
<div class="right-message message-item" v-if="item.type === 'user'">
|
||||
<div class="avatar">
|
||||
<el-avatar :src="userAvatar" />
|
||||
</div>
|
||||
<div class="message">
|
||||
<div>
|
||||
<el-text class="time">{{ formatDate(item.createTime) }}</el-text>
|
||||
</div>
|
||||
<div class="right-text-container">
|
||||
<div class="right-text">{{ item.content }}</div>
|
||||
</div>
|
||||
<div class="right-btns">
|
||||
<el-button class="btn-cus" link @click="copyContent(item.content)">
|
||||
<img class="btn-image" src="@/assets/ai/copy.svg" />
|
||||
</el-button>
|
||||
<el-button class="btn-cus" link @click="onDelete(item.id)">
|
||||
<img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" />
|
||||
</el-button>
|
||||
<el-button class="btn-cus" link @click="onRefresh(item)">
|
||||
<el-icon size="17"><RefreshRight /></el-icon>
|
||||
</el-button>
|
||||
<el-button class="btn-cus" link @click="onEdit(item)">
|
||||
<el-icon size="17"><Edit /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 回到底部 -->
|
||||
<div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
|
||||
<el-button :icon="ArrowDownBold" circle />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import MarkdownView from '@/components/MarkdownView/index.vue'
|
||||
import MessageKnowledge from './MessageKnowledge.vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
||||
import { ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
|
||||
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { copy } = useClipboard() // 初始化 copy 到粘贴板
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
|
||||
const messageContainer: any = ref(null)
|
||||
const isScrolling = ref(false) //用于判断用户是否在滚动
|
||||
|
||||
const userAvatar = computed(() => userStore.user.avatar || userAvatarDefaultImg)
|
||||
const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object as PropType<ChatConversationVO>,
|
||||
required: true
|
||||
},
|
||||
list: {
|
||||
type: Array as PropType<ChatMessageVO[]>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const { list } = toRefs(props) // 消息列表
|
||||
|
||||
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits
|
||||
|
||||
// ============ 处理对话滚动 ==============
|
||||
|
||||
/** 滚动到底部 */
|
||||
const scrollToBottom = async (isIgnore?: boolean) => {
|
||||
// 注意要使用 nextTick 以免获取不到 dom
|
||||
await nextTick()
|
||||
if (isIgnore || !isScrolling.value) {
|
||||
messageContainer.value.scrollTop =
|
||||
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const scrollContainer = messageContainer.value
|
||||
const scrollTop = scrollContainer.scrollTop
|
||||
const scrollHeight = scrollContainer.scrollHeight
|
||||
const offsetHeight = scrollContainer.offsetHeight
|
||||
if (scrollTop + offsetHeight < scrollHeight - 100) {
|
||||
// 用户开始滚动并在最底部之上,取消保持在最底部的效果
|
||||
isScrolling.value = true
|
||||
} else {
|
||||
// 用户停止滚动并滚动到最底部,开启保持到最底部的效果
|
||||
isScrolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 回到底部 */
|
||||
const handleGoBottom = async () => {
|
||||
const scrollContainer = messageContainer.value
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
|
||||
/** 回到顶部 */
|
||||
const handlerGoTop = async () => {
|
||||
const scrollContainer = messageContainer.value
|
||||
scrollContainer.scrollTop = 0
|
||||
}
|
||||
|
||||
defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用
|
||||
|
||||
// ============ 处理消息操作 ==============
|
||||
|
||||
/** 复制 */
|
||||
const copyContent = async (content) => {
|
||||
await copy(content)
|
||||
message.success('复制成功!')
|
||||
}
|
||||
|
||||
/** 删除 */
|
||||
const onDelete = async (id) => {
|
||||
// 删除 message
|
||||
await ChatMessageApi.deleteChatMessage(id)
|
||||
message.success('删除成功!')
|
||||
// 回调
|
||||
emits('onDeleteSuccess')
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
const onRefresh = async (message: ChatMessageVO) => {
|
||||
emits('onRefresh', message)
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
const onEdit = async (message: ChatMessageVO) => {
|
||||
emits('onEdit', message)
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
messageContainer.value.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-container {
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
// 中间
|
||||
.chat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
padding: 0 20px;
|
||||
.message-item {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.left-message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.right-message {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
margin: 0 15px;
|
||||
|
||||
.time {
|
||||
text-align: left;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.left-text-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-wrap: break-word;
|
||||
background-color: rgba(228, 228, 228, 0.8);
|
||||
box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8);
|
||||
border-radius: 10px;
|
||||
padding: 10px 10px 5px 10px;
|
||||
|
||||
.left-text {
|
||||
color: #393939;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.right-text-container {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.right-text {
|
||||
font-size: 0.95rem;
|
||||
color: #fff;
|
||||
display: inline;
|
||||
background-color: #267fff;
|
||||
box-shadow: 0 0 0 1px #267fff;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
width: auto;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.left-btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.right-btns {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 复制、删除按钮
|
||||
.btn-cus {
|
||||
display: flex;
|
||||
background-color: transparent;
|
||||
align-items: center;
|
||||
|
||||
.btn-image {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cus:hover {
|
||||
cursor: pointer;
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
}
|
||||
|
||||
// 回到底部
|
||||
.to-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
bottom: 0;
|
||||
right: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!-- 消息列表为空时,展示 prompt 列表 -->
|
||||
<template>
|
||||
<div class="chat-empty">
|
||||
<!-- title -->
|
||||
<div class="center-container">
|
||||
<div class="title">芋道 AI</div>
|
||||
<div class="role-list">
|
||||
<div
|
||||
class="role-item"
|
||||
v-for="prompt in promptList"
|
||||
:key="prompt.prompt"
|
||||
@click="handlerPromptClick(prompt)"
|
||||
>
|
||||
{{ prompt.prompt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const promptList = [
|
||||
{
|
||||
prompt: '今天气怎么样?'
|
||||
},
|
||||
{
|
||||
prompt: '写一首好听的诗歌?'
|
||||
}
|
||||
] // prompt 列表
|
||||
|
||||
const emits = defineEmits(['onPrompt'])
|
||||
|
||||
/** 选中 prompt 点击 */
|
||||
const handlerPromptClick = async ({ prompt }) => {
|
||||
emits('onPrompt', prompt)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.chat-empty {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.center-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.role-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 460px;
|
||||
margin-top: 20px;
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 180px;
|
||||
line-height: 50px;
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 10px;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.role-item:hover {
|
||||
background-color: rgba(243, 243, 243, 0.73);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- message 加载页面 -->
|
||||
<template>
|
||||
<div class="message-loading" >
|
||||
<el-skeleton animated />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.message-loading {
|
||||
padding: 30px 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- 无聊天对话时,在 message 区域,可以新增对话 -->
|
||||
<template>
|
||||
<div class="new-chat">
|
||||
<div class="box-center">
|
||||
<div class="tip">点击下方按钮,开始你的对话吧</div>
|
||||
<div class="btns">
|
||||
<el-button type="primary" round @click="handlerNewChat">新建对话</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const emits = defineEmits(['onNewConversation'])
|
||||
|
||||
/** 新建 conversation 聊天对话 */
|
||||
const handlerNewChat = () => {
|
||||
emits('onNewConversation')
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.new-chat {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.box-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
color: #858585;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
src/views/ai/chat/index/components/role/RoleCategoryList.vue
Normal file
53
src/views/ai/chat/index/components/role/RoleCategoryList.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="category-list">
|
||||
<div class="category" v-for="category in categoryList" :key="category">
|
||||
<el-button
|
||||
plain
|
||||
round
|
||||
size="small"
|
||||
:type="category === active ? 'primary' : ''"
|
||||
@click="handleCategoryClick(category)"
|
||||
>
|
||||
{{ category }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue'
|
||||
|
||||
// 定义属性
|
||||
defineProps({
|
||||
categoryList: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true
|
||||
},
|
||||
active: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '全部'
|
||||
}
|
||||
})
|
||||
|
||||
// 定义回调
|
||||
const emits = defineEmits(['onCategoryClick'])
|
||||
|
||||
/** 处理分类点击事件 */
|
||||
const handleCategoryClick = async (category: string) => {
|
||||
emits('onCategoryClick', category)
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
48
src/views/ai/chat/index/components/role/RoleHeader.vue
Normal file
48
src/views/ai/chat/index/components/role/RoleHeader.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<!-- header -->
|
||||
<template>
|
||||
<el-header class="chat-header">
|
||||
<div class="title">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="title-right">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</el-header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 设置组件属性
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
background-color: #ececec;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
color: #3e3e3e;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.title-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
174
src/views/ai/chat/index/components/role/RoleList.vue
Normal file
174
src/views/ai/chat/index/components/role/RoleList.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
|
||||
<div class="card-item" v-for="role in roleList" :key="role.id">
|
||||
<el-card class="card" body-class="card-body">
|
||||
<!-- 更多操作 -->
|
||||
<div class="more-container" v-if="showMore">
|
||||
<el-dropdown @command="handleMoreClick">
|
||||
<span class="el-dropdown-link">
|
||||
<el-button type="text">
|
||||
<el-icon><More /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item :command="['edit', role]">
|
||||
<Icon icon="ep:edit" color="#787878" />编辑
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item :command="['delete', role]" style="color: red">
|
||||
<Icon icon="ep:delete" color="red" />删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<!-- 角色信息 -->
|
||||
<div>
|
||||
<img class="avatar" :src="role.avatar" />
|
||||
</div>
|
||||
<div class="right-container">
|
||||
<div class="content-container">
|
||||
<div class="title">{{ role.name }}</div>
|
||||
<div class="description">{{ role.description }}</div>
|
||||
</div>
|
||||
<div class="btn-container">
|
||||
<el-button type="primary" size="small" @click="handleUseClick(role)">使用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChatRoleVO } from '@/api/ai/model/chatRole'
|
||||
import { PropType, ref } from 'vue'
|
||||
import { More } from '@element-plus/icons-vue'
|
||||
|
||||
const tabsRef = ref<any>() // tabs ref
|
||||
|
||||
// 定义属性
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
roleList: {
|
||||
type: Array as PropType<ChatRoleVO[]>,
|
||||
required: true
|
||||
},
|
||||
showMore: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 定义钩子
|
||||
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
|
||||
|
||||
/** 操作:编辑、删除 */
|
||||
const handleMoreClick = async (data) => {
|
||||
const type = data[0]
|
||||
const role = data[1]
|
||||
if (type === 'delete') {
|
||||
emits('onDelete', role)
|
||||
} else {
|
||||
emits('onEdit', role)
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中 */
|
||||
const handleUseClick = (role) => {
|
||||
emits('onUse', role)
|
||||
}
|
||||
|
||||
/** 滚动 */
|
||||
const handleTabsScroll = async () => {
|
||||
if (tabsRef.value) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value
|
||||
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
|
||||
await emits('onPage')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// 重写 card 组件 body 样式
|
||||
.card-body {
|
||||
max-width: 240px;
|
||||
width: 240px;
|
||||
padding: 15px 15px 10px 15px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
// 卡片列表
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 0px 25px;
|
||||
padding-bottom: 140px;
|
||||
align-items: start;
|
||||
align-content: flex-start;
|
||||
justify-content: start;
|
||||
|
||||
.card {
|
||||
display: inline-block;
|
||||
margin-right: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
|
||||
.more-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-container {
|
||||
margin-left: 10px;
|
||||
width: 100%;
|
||||
//height: 100px;
|
||||
|
||||
.content-container {
|
||||
height: 85px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #3e3e3e;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: #6a6a6a;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
289
src/views/ai/chat/index/components/role/RoleRepository.vue
Normal file
289
src/views/ai/chat/index/components/role/RoleRepository.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<!-- chat 角色仓库 -->
|
||||
<template>
|
||||
<el-container class="role-container">
|
||||
<ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
|
||||
<!-- header -->
|
||||
<RoleHeader title="角色仓库" class="relative" />
|
||||
<!-- main -->
|
||||
<el-main class="role-main">
|
||||
<div class="search-container">
|
||||
<!-- 搜索按钮 -->
|
||||
<el-input
|
||||
:loading="loading"
|
||||
v-model="search"
|
||||
class="search-input"
|
||||
size="default"
|
||||
placeholder="请输入搜索的内容"
|
||||
:suffix-icon="Search"
|
||||
@change="getActiveTabsRole"
|
||||
/>
|
||||
<el-button
|
||||
v-if="activeTab == 'my-role'"
|
||||
type="primary"
|
||||
@click="handlerAddRole"
|
||||
class="ml-20px"
|
||||
>
|
||||
<Icon icon="ep:user" style="margin-right: 5px;" />
|
||||
添加角色
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- tabs -->
|
||||
<el-tabs v-model="activeTab" class="tabs" @tab-click="handleTabsClick">
|
||||
<el-tab-pane class="role-pane" label="我的角色" name="my-role">
|
||||
<RoleList
|
||||
:loading="loading"
|
||||
:role-list="myRoleList"
|
||||
:show-more="true"
|
||||
@on-delete="handlerCardDelete"
|
||||
@on-edit="handlerCardEdit"
|
||||
@on-use="handlerCardUse"
|
||||
@on-page="handlerCardPage('my')"
|
||||
class="mt-20px"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="公共角色" name="public-role">
|
||||
<RoleCategoryList
|
||||
class="role-category-list"
|
||||
:category-list="categoryList"
|
||||
:active="activeCategory"
|
||||
@on-category-click="handlerCategoryClick"
|
||||
/>
|
||||
<RoleList
|
||||
:role-list="publicRoleList"
|
||||
@on-delete="handlerCardDelete"
|
||||
@on-edit="handlerCardEdit"
|
||||
@on-use="handlerCardUse"
|
||||
@on-page="handlerCardPage('public')"
|
||||
class="mt-20px"
|
||||
loading
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import RoleHeader from './RoleHeader.vue'
|
||||
import RoleList from './RoleList.vue'
|
||||
import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
|
||||
import RoleCategoryList from './RoleCategoryList.vue'
|
||||
import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole'
|
||||
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
|
||||
import {Search} from '@element-plus/icons-vue'
|
||||
import {TabsPaneContext} from 'element-plus'
|
||||
|
||||
const router = useRouter() // 路由对象
|
||||
|
||||
// 属性定义
|
||||
const loading = ref<boolean>(false) // 加载中
|
||||
const activeTab = ref<string>('my-role') // 选中的角色 Tab
|
||||
const search = ref<string>('') // 加载中
|
||||
const myRoleParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 50
|
||||
})
|
||||
const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小
|
||||
const publicRoleParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 50
|
||||
})
|
||||
const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小
|
||||
const activeCategory = ref<string>('全部') // 选择中的分类
|
||||
const categoryList = ref<string[]>([]) // 角色分类类别
|
||||
|
||||
/** tabs 点击 */
|
||||
const handleTabsClick = async (tab: TabsPaneContext) => {
|
||||
// 设置切换状态
|
||||
activeTab.value = tab.paneName + ''
|
||||
// 切换的时候重新加载数据
|
||||
await getActiveTabsRole()
|
||||
}
|
||||
|
||||
/** 获取 my role 我的角色 */
|
||||
const getMyRole = async (append?: boolean) => {
|
||||
const params: ChatRolePageReqVO = {
|
||||
...myRoleParams,
|
||||
name: search.value,
|
||||
publicStatus: false
|
||||
}
|
||||
const { list } = await ChatRoleApi.getMyPage(params)
|
||||
if (append) {
|
||||
myRoleList.value.push.apply(myRoleList.value, list)
|
||||
} else {
|
||||
myRoleList.value = list
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取 public role 公共角色 */
|
||||
const getPublicRole = async (append?: boolean) => {
|
||||
const params: ChatRolePageReqVO = {
|
||||
...publicRoleParams,
|
||||
category: activeCategory.value === '全部' ? '' : activeCategory.value,
|
||||
name: search.value,
|
||||
publicStatus: true
|
||||
}
|
||||
const { total, list } = await ChatRoleApi.getMyPage(params)
|
||||
if (append) {
|
||||
publicRoleList.value.push.apply(publicRoleList.value, list)
|
||||
} else {
|
||||
publicRoleList.value = list
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取选中的 tabs 角色 */
|
||||
const getActiveTabsRole = async () => {
|
||||
if (activeTab.value === 'my-role') {
|
||||
myRoleParams.pageNo = 1
|
||||
await getMyRole()
|
||||
} else {
|
||||
publicRoleParams.pageNo = 1
|
||||
await getPublicRole()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取角色分类列表 */
|
||||
const getRoleCategoryList = async () => {
|
||||
categoryList.value = ['全部', ...(await ChatRoleApi.getCategoryList())]
|
||||
}
|
||||
|
||||
/** 处理分类点击 */
|
||||
const handlerCategoryClick = async (category: string) => {
|
||||
// 切换选择的分类
|
||||
activeCategory.value = category
|
||||
// 筛选
|
||||
await getActiveTabsRole()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const handlerAddRole = async () => {
|
||||
formRef.value.open('my-create', null, '添加角色')
|
||||
}
|
||||
/** 编辑角色 */
|
||||
const handlerCardEdit = async (role) => {
|
||||
formRef.value.open('my-update', role.id, '编辑角色')
|
||||
}
|
||||
|
||||
/** 添加角色成功 */
|
||||
const handlerAddRoleSuccess = async (e) => {
|
||||
// 刷新数据
|
||||
await getActiveTabsRole()
|
||||
}
|
||||
|
||||
/** 删除角色 */
|
||||
const handlerCardDelete = async (role) => {
|
||||
await ChatRoleApi.deleteMy(role.id)
|
||||
// 刷新数据
|
||||
await getActiveTabsRole()
|
||||
}
|
||||
|
||||
/** 角色分页:获取下一页 */
|
||||
const handlerCardPage = async (type) => {
|
||||
try {
|
||||
loading.value = true
|
||||
if (type === 'public') {
|
||||
publicRoleParams.pageNo++
|
||||
await getPublicRole(true)
|
||||
} else {
|
||||
myRoleParams.pageNo++
|
||||
await getMyRole(true)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择 card 角色:新建聊天对话 */
|
||||
const handlerCardUse = async (role) => {
|
||||
// 1. 创建对话
|
||||
const data: ChatConversationVO = {
|
||||
roleId: role.id
|
||||
} as unknown as ChatConversationVO
|
||||
const conversationId = await ChatConversationApi.createChatConversationMy(data)
|
||||
|
||||
// 2. 跳转页面
|
||||
await router.push({
|
||||
name: 'AiChat',
|
||||
query: {
|
||||
conversationId: conversationId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
// 获取分类
|
||||
await getRoleCategoryList()
|
||||
// 获取 role 数据
|
||||
await getActiveTabsRole()
|
||||
})
|
||||
</script>
|
||||
<!-- 覆盖 element ui css -->
|
||||
<style lang="scss">
|
||||
.el-tabs__content {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-tabs__nav-scroll {
|
||||
margin: 10px 20px;
|
||||
}
|
||||
</style>
|
||||
<!-- 样式 -->
|
||||
<style scoped lang="scss">
|
||||
// 跟容器
|
||||
.role-container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: #ffffff;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.role-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
.search-container {
|
||||
margin: 20px 20px 0px 20px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -5px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
.role-category-list {
|
||||
margin: 0 27px;
|
||||
}
|
||||
}
|
||||
|
||||
.role-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
774
src/views/ai/chat/index/index.vue
Normal file
774
src/views/ai/chat/index/index.vue
Normal file
@@ -0,0 +1,774 @@
|
||||
<template>
|
||||
<el-container class="ai-layout">
|
||||
<!-- 左侧:对话列表 -->
|
||||
<ConversationList
|
||||
:active-id="activeConversationId"
|
||||
ref="conversationListRef"
|
||||
@on-conversation-create="handleConversationCreateSuccess"
|
||||
@on-conversation-click="handleConversationClick"
|
||||
@on-conversation-clear="handleConversationClear"
|
||||
@on-conversation-delete="handlerConversationDelete"
|
||||
/>
|
||||
<!-- 右侧:对话详情 -->
|
||||
<el-container class="detail-container">
|
||||
<el-header class="header">
|
||||
<div class="title">
|
||||
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
|
||||
<span v-if="activeMessageList.length">({{ activeMessageList.length }})</span>
|
||||
</div>
|
||||
<div class="btns" v-if="activeConversation">
|
||||
<el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
|
||||
<span v-html="activeConversation?.modelName"></span>
|
||||
<Icon icon="ep:setting" class="ml-10px" />
|
||||
</el-button>
|
||||
<el-button size="small" class="btn" @click="handlerMessageClear">
|
||||
<Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
|
||||
</el-button>
|
||||
<el-button size="small" class="btn">
|
||||
<Icon icon="ep:download" color="#787878" />
|
||||
</el-button>
|
||||
<el-button size="small" class="btn" @click="handleGoTopMessage">
|
||||
<Icon icon="ep:top" color="#787878" />
|
||||
</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- main:消息列表 -->
|
||||
<el-main class="main-container">
|
||||
<div>
|
||||
<div class="message-container">
|
||||
<!-- 情况一:消息加载中 -->
|
||||
<MessageLoading v-if="activeMessageListLoading" />
|
||||
<!-- 情况二:无聊天对话时 -->
|
||||
<MessageNewConversation
|
||||
v-if="!activeConversation"
|
||||
@on-new-conversation="handleConversationCreate"
|
||||
/>
|
||||
<!-- 情况三:消息列表为空 -->
|
||||
<MessageListEmpty
|
||||
v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation"
|
||||
@on-prompt="doSendMessage"
|
||||
/>
|
||||
<!-- 情况四:消息列表不为空 -->
|
||||
<MessageList
|
||||
v-if="!activeMessageListLoading && messageList.length > 0"
|
||||
ref="messageRef"
|
||||
:conversation="activeConversation"
|
||||
:list="messageList"
|
||||
@on-delete-success="handleMessageDelete"
|
||||
@on-edit="handleMessageEdit"
|
||||
@on-refresh="handleMessageRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
|
||||
<!-- 底部 -->
|
||||
<el-footer class="footer-container">
|
||||
<form class="prompt-from">
|
||||
<textarea
|
||||
class="prompt-input"
|
||||
v-model="prompt"
|
||||
@keydown="handleSendByKeydown"
|
||||
@input="handlePromptInput"
|
||||
@compositionstart="onCompositionstart"
|
||||
@compositionend="onCompositionend"
|
||||
placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
|
||||
></textarea>
|
||||
<div class="prompt-btns">
|
||||
<div>
|
||||
<el-switch v-model="enableContext" />
|
||||
<span class="ml-5px text-14px text-#8f8f8f">上下文</span>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
@click="handleSendByButton"
|
||||
:loading="conversationInProgress"
|
||||
v-if="conversationInProgress == false"
|
||||
>
|
||||
{{ conversationInProgress ? '进行中' : '发送' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="default"
|
||||
@click="stopStream()"
|
||||
v-if="conversationInProgress == true"
|
||||
>
|
||||
停止
|
||||
</el-button>
|
||||
</div>
|
||||
</form>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
|
||||
<!-- 更新对话 Form -->
|
||||
<ConversationUpdateForm
|
||||
ref="conversationUpdateFormRef"
|
||||
@success="handleConversationUpdateSuccess"
|
||||
/>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||
import ConversationList from './components/conversation/ConversationList.vue'
|
||||
import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue'
|
||||
import MessageList from './components/message/MessageList.vue'
|
||||
import MessageListEmpty from './components/message/MessageListEmpty.vue'
|
||||
import MessageLoading from './components/message/MessageLoading.vue'
|
||||
import MessageNewConversation from './components/message/MessageNewConversation.vue'
|
||||
|
||||
/** AI 聊天对话 列表 */
|
||||
defineOptions({ name: 'AiChat' })
|
||||
|
||||
const route = useRoute() // 路由
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
// 聊天对话
|
||||
const conversationListRef = ref()
|
||||
const activeConversationId = ref<number | null>(null) // 选中的对话编号
|
||||
const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
|
||||
const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
|
||||
|
||||
// 消息列表
|
||||
const messageRef = ref()
|
||||
const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表
|
||||
const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中
|
||||
const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
|
||||
// 消息滚动
|
||||
const textSpeed = ref<number>(50) // Typing speed in milliseconds
|
||||
const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
|
||||
|
||||
// 发送消息输入框
|
||||
const isComposing = ref(false) // 判断用户是否在输入
|
||||
const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
|
||||
const inputTimeout = ref<any>() // 处理输入中回车的定时器
|
||||
const prompt = ref<string>() // prompt
|
||||
const enableContext = ref<boolean>(true) // 是否开启上下文
|
||||
// 接收 Stream 消息
|
||||
const receiveMessageFullText = ref('')
|
||||
const receiveMessageDisplayedText = ref('')
|
||||
|
||||
// =========== 【聊天对话】相关 ===========
|
||||
|
||||
/** 获取对话信息 */
|
||||
const getConversation = async (id: number | null) => {
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
|
||||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
activeConversation.value = conversation
|
||||
activeConversationId.value = conversation.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击某个对话
|
||||
*
|
||||
* @param conversation 选中的对话
|
||||
* @return 是否切换成功
|
||||
*/
|
||||
const handleConversationClick = async (conversation: ChatConversationVO) => {
|
||||
// 对话进行中,不允许切换
|
||||
if (conversationInProgress.value) {
|
||||
message.alert('对话中,不允许切换!')
|
||||
return false
|
||||
}
|
||||
|
||||
// 更新选中的对话 id
|
||||
activeConversationId.value = conversation.id
|
||||
activeConversation.value = conversation
|
||||
// 刷新 message 列表
|
||||
await getMessageList()
|
||||
// 滚动底部
|
||||
scrollToBottom(true)
|
||||
// 清空输入框
|
||||
prompt.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
/** 删除某个对话*/
|
||||
const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
|
||||
// 删除的对话如果是当前选中的,那么就重置
|
||||
if (activeConversationId.value === delConversation.id) {
|
||||
await handleConversationClear()
|
||||
}
|
||||
}
|
||||
/** 清空选中的对话 */
|
||||
const handleConversationClear = async () => {
|
||||
// 对话进行中,不允许切换
|
||||
if (conversationInProgress.value) {
|
||||
message.alert('对话中,不允许切换!')
|
||||
return false
|
||||
}
|
||||
activeConversationId.value = null
|
||||
activeConversation.value = null
|
||||
activeMessageList.value = []
|
||||
}
|
||||
|
||||
/** 修改聊天对话 */
|
||||
const conversationUpdateFormRef = ref()
|
||||
const openChatConversationUpdateForm = async () => {
|
||||
conversationUpdateFormRef.value.open(activeConversationId.value)
|
||||
}
|
||||
const handleConversationUpdateSuccess = async () => {
|
||||
// 对话更新成功,刷新最新信息
|
||||
await getConversation(activeConversationId.value)
|
||||
}
|
||||
|
||||
/** 处理聊天对话的创建成功 */
|
||||
const handleConversationCreate = async () => {
|
||||
// 创建对话
|
||||
await conversationListRef.value.createConversation()
|
||||
}
|
||||
/** 处理聊天对话的创建成功 */
|
||||
const handleConversationCreateSuccess = async () => {
|
||||
// 创建新的对话,清空输入框
|
||||
prompt.value = ''
|
||||
}
|
||||
|
||||
// =========== 【消息列表】相关 ===========
|
||||
|
||||
/** 获取消息 message 列表 */
|
||||
const getMessageList = async () => {
|
||||
try {
|
||||
if (activeConversationId.value === null) {
|
||||
return
|
||||
}
|
||||
// Timer 定时器,如果加载速度很快,就不进入加载中
|
||||
activeMessageListLoadingTimer.value = setTimeout(() => {
|
||||
activeMessageListLoading.value = true
|
||||
}, 60)
|
||||
|
||||
// 获取消息列表
|
||||
activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
|
||||
activeConversationId.value
|
||||
)
|
||||
|
||||
// 滚动到最下面
|
||||
await nextTick()
|
||||
await scrollToBottom()
|
||||
} finally {
|
||||
// time 定时器,如果加载速度很快,就不进入加载中
|
||||
if (activeMessageListLoadingTimer.value) {
|
||||
clearTimeout(activeMessageListLoadingTimer.value)
|
||||
}
|
||||
// 加载结束
|
||||
activeMessageListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息列表
|
||||
*
|
||||
* 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
|
||||
*/
|
||||
const messageList = computed(() => {
|
||||
if (activeMessageList.value.length > 0) {
|
||||
return activeMessageList.value
|
||||
}
|
||||
// 没有消息时,如果有 systemMessage 则展示它
|
||||
if (activeConversation.value?.systemMessage) {
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
type: 'system',
|
||||
content: activeConversation.value.systemMessage
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
/** 处理删除 message 消息 */
|
||||
const handleMessageDelete = () => {
|
||||
if (conversationInProgress.value) {
|
||||
message.alert('回答中,不能删除!')
|
||||
return
|
||||
}
|
||||
// 刷新 message 列表
|
||||
getMessageList()
|
||||
}
|
||||
|
||||
/** 处理 message 清空 */
|
||||
const handlerMessageClear = async () => {
|
||||
if (!activeConversationId.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 确认提示
|
||||
await message.delConfirm('确认清空对话消息?')
|
||||
// 清空对话
|
||||
await ChatMessageApi.deleteByConversationId(activeConversationId.value)
|
||||
// 刷新 message 列表
|
||||
activeMessageList.value = []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 回到 message 列表的顶部 */
|
||||
const handleGoTopMessage = () => {
|
||||
messageRef.value.handlerGoTop()
|
||||
}
|
||||
|
||||
// =========== 【发送消息】相关 ===========
|
||||
|
||||
/** 处理来自 keydown 的发送消息 */
|
||||
const handleSendByKeydown = async (event) => {
|
||||
// 判断用户是否在输入
|
||||
if (isComposing.value) {
|
||||
return
|
||||
}
|
||||
// 进行中不允许发送
|
||||
if (conversationInProgress.value) {
|
||||
return
|
||||
}
|
||||
const content = prompt.value?.trim() as string
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
// 插入换行
|
||||
prompt.value += '\r\n'
|
||||
event.preventDefault() // 防止默认的换行行为
|
||||
} else {
|
||||
// 发送消息
|
||||
await doSendMessage(content)
|
||||
event.preventDefault() // 防止默认的提交行为
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理来自【发送】按钮的发送消息 */
|
||||
const handleSendByButton = () => {
|
||||
doSendMessage(prompt.value?.trim() as string)
|
||||
}
|
||||
|
||||
/** 处理 prompt 输入变化 */
|
||||
const handlePromptInput = (event) => {
|
||||
// 非输入法 输入设置为 true
|
||||
if (!isComposing.value) {
|
||||
// 回车 event data 是 null
|
||||
if (event.data == null) {
|
||||
return
|
||||
}
|
||||
isComposing.value = true
|
||||
}
|
||||
// 清理定时器
|
||||
if (inputTimeout.value) {
|
||||
clearTimeout(inputTimeout.value)
|
||||
}
|
||||
// 重置定时器
|
||||
inputTimeout.value = setTimeout(() => {
|
||||
isComposing.value = false
|
||||
}, 400)
|
||||
}
|
||||
// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
|
||||
const onCompositionstart = () => {
|
||||
isComposing.value = true
|
||||
}
|
||||
const onCompositionend = () => {
|
||||
// console.log('输入结束...')
|
||||
setTimeout(() => {
|
||||
isComposing.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/** 真正执行【发送】消息操作 */
|
||||
const doSendMessage = async (content: string) => {
|
||||
// 校验
|
||||
if (content.length < 1) {
|
||||
message.error('发送失败,原因:内容为空!')
|
||||
return
|
||||
}
|
||||
if (activeConversationId.value == null) {
|
||||
message.error('还没创建对话,不能发送!')
|
||||
return
|
||||
}
|
||||
// 清空输入框
|
||||
prompt.value = ''
|
||||
// 执行发送
|
||||
await doSendMessageStream({
|
||||
conversationId: activeConversationId.value,
|
||||
content: content
|
||||
} as ChatMessageVO)
|
||||
}
|
||||
|
||||
/** 真正执行【发送】消息操作 */
|
||||
const doSendMessageStream = async (userMessage: ChatMessageVO) => {
|
||||
// 创建 AbortController 实例,以便中止请求
|
||||
conversationInAbortController.value = new AbortController()
|
||||
// 标记对话进行中
|
||||
conversationInProgress.value = true
|
||||
// 设置为空
|
||||
receiveMessageFullText.value = ''
|
||||
|
||||
try {
|
||||
// 1.1 先添加两个假数据,等 stream 返回再替换
|
||||
activeMessageList.value.push({
|
||||
id: -1,
|
||||
conversationId: activeConversationId.value,
|
||||
type: 'user',
|
||||
content: userMessage.content,
|
||||
createTime: new Date()
|
||||
} as ChatMessageVO)
|
||||
activeMessageList.value.push({
|
||||
id: -2,
|
||||
conversationId: activeConversationId.value,
|
||||
type: 'assistant',
|
||||
content: '思考中...',
|
||||
createTime: new Date()
|
||||
} as ChatMessageVO)
|
||||
// 1.2 滚动到最下面
|
||||
await nextTick()
|
||||
await scrollToBottom() // 底部
|
||||
// 1.3 开始滚动
|
||||
textRoll()
|
||||
|
||||
// 2. 发送 event stream
|
||||
let isFirstChunk = true // 是否是第一个 chunk 消息段
|
||||
await ChatMessageApi.sendChatMessageStream(
|
||||
userMessage.conversationId,
|
||||
userMessage.content,
|
||||
conversationInAbortController.value,
|
||||
enableContext.value,
|
||||
async (res) => {
|
||||
const { code, data, msg } = JSON.parse(res.data)
|
||||
if (code !== 0) {
|
||||
message.alert(`对话异常! ${msg}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果内容为空,就不处理。
|
||||
if (data.receive.content === '') {
|
||||
return
|
||||
}
|
||||
// 首次返回需要添加一个 message 到页面,后面的都是更新
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
// 弹出两个假数据
|
||||
activeMessageList.value.pop()
|
||||
activeMessageList.value.pop()
|
||||
// 更新返回的数据
|
||||
activeMessageList.value.push(data.send)
|
||||
activeMessageList.value.push(data.receive)
|
||||
}
|
||||
// debugger
|
||||
receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
|
||||
// 滚动到最下面
|
||||
await scrollToBottom()
|
||||
},
|
||||
(error) => {
|
||||
message.alert(`对话异常! ${error}`)
|
||||
stopStream()
|
||||
// 需要抛出异常,禁止重试
|
||||
throw error
|
||||
},
|
||||
() => {
|
||||
stopStream()
|
||||
}
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 停止 stream 流式调用 */
|
||||
const stopStream = async () => {
|
||||
// tip:如果 stream 进行中的 message,就需要调用 controller 结束
|
||||
if (conversationInAbortController.value) {
|
||||
conversationInAbortController.value.abort()
|
||||
}
|
||||
// 设置为 false
|
||||
conversationInProgress.value = false
|
||||
}
|
||||
|
||||
/** 编辑 message:设置为 prompt,可以再次编辑 */
|
||||
const handleMessageEdit = (message: ChatMessageVO) => {
|
||||
prompt.value = message.content
|
||||
}
|
||||
|
||||
/** 刷新 message:基于指定消息,再次发起对话 */
|
||||
const handleMessageRefresh = (message: ChatMessageVO) => {
|
||||
doSendMessage(message.content)
|
||||
}
|
||||
|
||||
// ============== 【消息滚动】相关 =============
|
||||
|
||||
/** 滚动到 message 底部 */
|
||||
const scrollToBottom = async (isIgnore?: boolean) => {
|
||||
await nextTick()
|
||||
if (messageRef.value) {
|
||||
messageRef.value.scrollToBottom(isIgnore)
|
||||
}
|
||||
}
|
||||
|
||||
/** 自提滚动效果 */
|
||||
const textRoll = async () => {
|
||||
let index = 0
|
||||
try {
|
||||
// 只能执行一次
|
||||
if (textRoleRunning.value) {
|
||||
return
|
||||
}
|
||||
// 设置状态
|
||||
textRoleRunning.value = true
|
||||
receiveMessageDisplayedText.value = ''
|
||||
const task = async () => {
|
||||
// 调整速度
|
||||
const diff =
|
||||
(receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10
|
||||
if (diff > 5) {
|
||||
textSpeed.value = 10
|
||||
} else if (diff > 2) {
|
||||
textSpeed.value = 30
|
||||
} else if (diff > 1.5) {
|
||||
textSpeed.value = 50
|
||||
} else {
|
||||
textSpeed.value = 100
|
||||
}
|
||||
// 对话结束,就按 30 的速度
|
||||
if (!conversationInProgress.value) {
|
||||
textSpeed.value = 10
|
||||
}
|
||||
|
||||
if (index < receiveMessageFullText.value.length) {
|
||||
receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
|
||||
index++
|
||||
|
||||
// 更新 message
|
||||
const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
|
||||
lastMessage.content = receiveMessageDisplayedText.value
|
||||
// 滚动到住下面
|
||||
await scrollToBottom()
|
||||
// 重新设置任务
|
||||
timer = setTimeout(task, textSpeed.value)
|
||||
} else {
|
||||
// 不是对话中可以结束
|
||||
if (!conversationInProgress.value) {
|
||||
textRoleRunning.value = false
|
||||
clearTimeout(timer)
|
||||
} else {
|
||||
// 重新设置任务
|
||||
timer = setTimeout(task, textSpeed.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
let timer = setTimeout(task, textSpeed.value)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
// 如果有 conversationId 参数,则默认选中
|
||||
if (route.query.conversationId) {
|
||||
const id = route.query.conversationId as unknown as number
|
||||
activeConversationId.value = id
|
||||
await getConversation(id)
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
activeMessageListLoading.value = true
|
||||
await getMessageList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ai-layout {
|
||||
position: absolute;
|
||||
flex: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.conversation-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px 0;
|
||||
|
||||
.btn-new-conversation {
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
margin-top: 20px;
|
||||
|
||||
.conversation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding: 0 5px;
|
||||
margin-top: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
align-items: center;
|
||||
line-height: 30px;
|
||||
|
||||
&.active {
|
||||
background-color: #e6e6e6;
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 5px 10px;
|
||||
max-width: 220px;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
// 对话编辑、删除
|
||||
.button-wrapper {
|
||||
right: 2px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-items: center;
|
||||
color: #606266;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 角色仓库、清空未设置对话
|
||||
.tool-box {
|
||||
line-height: 35px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--el-text-color);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #606266;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
> span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 头部
|
||||
.detail-container {
|
||||
background: #ffffff;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fbfbfb;
|
||||
box-shadow: 0 0 0 0 #dcdfe6;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
width: 300px;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
//justify-content: space-between;
|
||||
|
||||
.btn {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// main 容器
|
||||
.main-container {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.message-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow-y: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部
|
||||
.footer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.prompt-from {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 10px;
|
||||
margin: 10px 20px 20px 20px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
height: 80px;
|
||||
//box-shadow: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
resize: none;
|
||||
padding: 0 2px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.prompt-btns {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0;
|
||||
padding-top: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
src/views/ai/chat/manager/ChatConversationList.vue
Normal file
163
src/views/ai/chat/manager/ChatConversationList.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-select
|
||||
v-model="queryParams.userId"
|
||||
clearable
|
||||
placeholder="请输入用户编号"
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in userList"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="聊天编号" prop="title">
|
||||
<el-input
|
||||
v-model="queryParams.title"
|
||||
placeholder="请输入聊天编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="对话编号" align="center" prop="id" width="180" fixed="left" />
|
||||
<el-table-column label="对话标题" align="center" prop="title" width="180" fixed="left" />
|
||||
<el-table-column label="用户" align="center" prop="userId" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" align="center" prop="roleName" width="180" />
|
||||
<el-table-column label="模型标识" align="center" prop="model" width="180" />
|
||||
<el-table-column label="消息数" align="center" prop="messageCount" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="温度参数" align="center" prop="temperature" />
|
||||
<el-table-column label="回复 Token 数" align="center" prop="maxTokens" width="120" />
|
||||
<el-table-column label="上下文数量" align="center" prop="maxContexts" width="120" />
|
||||
<el-table-column label="操作" align="center" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:chat-conversation:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ChatConversationVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
title: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const userList = ref<UserApi.UserVO[]>([]) // 用户列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ChatConversationApi.getChatConversationPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ChatConversationApi.deleteChatConversationByAdmin(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
getList()
|
||||
// 获得用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
})
|
||||
</script>
|
||||
175
src/views/ai/chat/manager/ChatMessageList.vue
Normal file
175
src/views/ai/chat/manager/ChatMessageList.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="对话编号" prop="conversationId">
|
||||
<el-input
|
||||
v-model="queryParams.conversationId"
|
||||
placeholder="请输入对话编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-select
|
||||
v-model="queryParams.userId"
|
||||
clearable
|
||||
placeholder="请输入用户编号"
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in userList"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="消息编号" align="center" prop="id" width="180" fixed="left" />
|
||||
<el-table-column
|
||||
label="对话编号"
|
||||
align="center"
|
||||
prop="conversationId"
|
||||
width="180"
|
||||
fixed="left"
|
||||
/>
|
||||
<el-table-column label="用户" align="center" prop="userId" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" align="center" prop="roleName" width="180" />
|
||||
<el-table-column label="消息类型" align="center" prop="type" width="100" />
|
||||
<el-table-column label="模型标识" align="center" prop="model" width="180" />
|
||||
<el-table-column label="消息内容" align="center" prop="content" width="300" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="回复消息编号" align="center" prop="replyId" width="180" />
|
||||
<el-table-column label="携带上下文" align="center" prop="useContext" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.useContext" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:chat-message:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ChatMessageVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
conversationId: undefined,
|
||||
userId: undefined,
|
||||
content: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const userList = ref<UserApi.UserVO[]>([]) // 用户列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ChatMessageApi.getChatMessagePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ChatMessageApi.deleteChatMessageByAdmin(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
getList()
|
||||
// 获得用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
})
|
||||
</script>
|
||||
22
src/views/ai/chat/manager/index.vue
Normal file
22
src/views/ai/chat/manager/index.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<doc-alert title="AI 对话聊天" url="https://doc.iocoder.cn/ai/chat/" />
|
||||
|
||||
<ContentWrap>
|
||||
<el-tabs>
|
||||
<el-tab-pane label="对话列表">
|
||||
<ChatConversationList />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="消息列表">
|
||||
<ChatMessageList />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChatConversationList from './ChatConversationList.vue'
|
||||
import ChatMessageList from './ChatMessageList.vue'
|
||||
|
||||
/** AI 聊天对话 列表 */
|
||||
defineOptions({ name: 'AiChatManager' })
|
||||
</script>
|
||||
162
src/views/ai/image/index/components/ImageCard.vue
Normal file
162
src/views/ai/image/index/components/ImageCard.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<el-card body-class="" class="image-card">
|
||||
<div class="image-operation">
|
||||
<div>
|
||||
<el-button type="primary" text bg v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS">
|
||||
生成中
|
||||
</el-button>
|
||||
<el-button text bg v-else-if="detail?.status === AiImageStatusEnum.SUCCESS">
|
||||
已完成
|
||||
</el-button>
|
||||
<el-button type="danger" text bg v-else-if="detail?.status === AiImageStatusEnum.FAIL">
|
||||
异常
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- 操作区 -->
|
||||
<div>
|
||||
<el-button
|
||||
class="btn"
|
||||
text
|
||||
:icon="Download"
|
||||
@click="handleButtonClick('download', detail)"
|
||||
/>
|
||||
<el-button
|
||||
class="btn"
|
||||
text
|
||||
:icon="RefreshRight"
|
||||
@click="handleButtonClick('regeneration', detail)"
|
||||
/>
|
||||
<el-button class="btn" text :icon="Delete" @click="handleButtonClick('delete', detail)" />
|
||||
<el-button class="btn" text :icon="More" @click="handleButtonClick('more', detail)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-wrapper" ref="cardImageRef">
|
||||
<el-image
|
||||
class="image"
|
||||
:src="detail?.picUrl"
|
||||
:preview-src-list="[detail.picUrl]"
|
||||
preview-teleported
|
||||
/>
|
||||
<div v-if="detail?.status === AiImageStatusEnum.FAIL">
|
||||
{{ detail?.errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Midjourney 专属操作 -->
|
||||
<div class="image-mj-btns">
|
||||
<el-button
|
||||
size="small"
|
||||
v-for="button in detail?.buttons"
|
||||
:key="button"
|
||||
class="min-w-40px ml-0 mr-10px mt-5px"
|
||||
@click="handleMidjourneyBtnClick(button)"
|
||||
>
|
||||
{{ button.label }}{{ button.emoji }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Delete, Download, More, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ImageVO, ImageMidjourneyButtonsVO } from '@/api/ai/image'
|
||||
import { PropType } from 'vue'
|
||||
import { ElLoading, LoadingOptionsResolved } from 'element-plus'
|
||||
import { AiImageStatusEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
const message = useMessage() // 消息
|
||||
|
||||
const props = defineProps({
|
||||
detail: {
|
||||
type: Object as PropType<ImageVO>,
|
||||
require: true
|
||||
}
|
||||
})
|
||||
|
||||
const cardImageRef = ref<any>() // 卡片 image ref
|
||||
const cardImageLoadingInstance = ref<any>() // 卡片 image ref
|
||||
|
||||
/** 处理点击事件 */
|
||||
const handleButtonClick = async (type, detail: ImageVO) => {
|
||||
emits('onBtnClick', type, detail)
|
||||
}
|
||||
|
||||
/** 处理 Midjourney 按钮点击事件 */
|
||||
const handleMidjourneyBtnClick = async (button: ImageMidjourneyButtonsVO) => {
|
||||
// 确认窗体
|
||||
await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`)
|
||||
emits('onMjBtnClick', button, props.detail)
|
||||
}
|
||||
|
||||
const emits = defineEmits(['onBtnClick', 'onMjBtnClick']) // emits
|
||||
|
||||
/** 监听详情 */
|
||||
const { detail } = toRefs(props)
|
||||
watch(detail, async (newVal, oldVal) => {
|
||||
await handleLoading(newVal.status as string)
|
||||
})
|
||||
|
||||
/** 处理加载状态 */
|
||||
const handleLoading = async (status: number) => {
|
||||
// 情况一:如果是生成中,则设置加载中的 loading
|
||||
if (status === AiImageStatusEnum.IN_PROGRESS) {
|
||||
cardImageLoadingInstance.value = ElLoading.service({
|
||||
target: cardImageRef.value,
|
||||
text: '生成中...'
|
||||
} as LoadingOptionsResolved)
|
||||
// 情况二:如果已经生成结束,则移除 loading
|
||||
} else {
|
||||
if (cardImageLoadingInstance.value) {
|
||||
cardImageLoadingInstance.value.close()
|
||||
cardImageLoadingInstance.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await handleLoading(props.detail.status as string)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.image-card {
|
||||
width: 320px;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.image-operation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
.btn {
|
||||
//border: 1px solid red;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
height: 280px;
|
||||
flex: 1;
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.image-mj-btns {
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
224
src/views/ai/image/index/components/ImageDetail.vue
Normal file
224
src/views/ai/image/index/components/ImageDetail.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="showDrawer"
|
||||
title="图片详细"
|
||||
@close="handleDrawerClose"
|
||||
custom-class="drawer-class"
|
||||
>
|
||||
<!-- 图片 -->
|
||||
<div class="item">
|
||||
<div class="body">
|
||||
<el-image
|
||||
class="image"
|
||||
:src="detail?.picUrl"
|
||||
:preview-src-list="[detail.picUrl]"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 时间 -->
|
||||
<div class="item">
|
||||
<div class="tip">时间</div>
|
||||
<div class="body">
|
||||
<div>提交时间:{{ formatTime(detail.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
|
||||
<div>生成时间:{{ formatTime(detail.finishTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模型 -->
|
||||
<div class="item">
|
||||
<div class="tip">模型</div>
|
||||
<div class="body"> {{ detail.model }}({{ detail.height }}x{{ detail.width }}) </div>
|
||||
</div>
|
||||
<!-- 提示词 -->
|
||||
<div class="item">
|
||||
<div class="tip">提示词</div>
|
||||
<div class="body">
|
||||
{{ detail.prompt }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 地址 -->
|
||||
<div class="item">
|
||||
<div class="tip">图片地址</div>
|
||||
<div class="body">
|
||||
{{ detail.picUrl }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- StableDiffusion 专属区域 -->
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.sampler"
|
||||
>
|
||||
<div class="tip">采样方法</div>
|
||||
<div class="body">
|
||||
{{
|
||||
StableDiffusionSamplers.find(
|
||||
(item: ImageModelVO) => item.key === detail?.options?.sampler
|
||||
)?.name
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="item"
|
||||
v-if="
|
||||
detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.clipGuidancePreset
|
||||
"
|
||||
>
|
||||
<div class="tip">CLIP</div>
|
||||
<div class="body">
|
||||
{{
|
||||
StableDiffusionClipGuidancePresets.find(
|
||||
(item: ImageModelVO) => item.key === detail?.options?.clipGuidancePreset
|
||||
)?.name
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.stylePreset"
|
||||
>
|
||||
<div class="tip">风格</div>
|
||||
<div class="body">
|
||||
{{
|
||||
StableDiffusionStylePresets.find(
|
||||
(item: ImageModelVO) => item.key === detail?.options?.stylePreset
|
||||
)?.name
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.steps"
|
||||
>
|
||||
<div class="tip">迭代步数</div>
|
||||
<div class="body">
|
||||
{{ detail?.options?.steps }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.scale"
|
||||
>
|
||||
<div class="tip">引导系数</div>
|
||||
<div class="body">
|
||||
{{ detail?.options?.scale }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.seed"
|
||||
>
|
||||
<div class="tip">随机因子</div>
|
||||
<div class="body">
|
||||
{{ detail?.options?.seed }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Dall3 专属区域 -->
|
||||
<div class="item" v-if="detail.platform === AiPlatformEnum.OPENAI && detail?.options?.style">
|
||||
<div class="tip">风格选择</div>
|
||||
<div class="body">
|
||||
{{ Dall3StyleList.find((item: ImageModelVO) => item.key === detail?.options?.style)?.name }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Midjourney 专属区域 -->
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.version"
|
||||
>
|
||||
<div class="tip">模型版本</div>
|
||||
<div class="body">
|
||||
{{ detail?.options?.version }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="item"
|
||||
v-if="detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.referImageUrl"
|
||||
>
|
||||
<div class="tip">参考图</div>
|
||||
<div class="body">
|
||||
<el-image :src="detail.options.referImageUrl" />
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ImageApi, ImageVO } from '@/api/ai/image'
|
||||
import {
|
||||
AiPlatformEnum,
|
||||
Dall3StyleList,
|
||||
ImageModelVO,
|
||||
StableDiffusionClipGuidancePresets,
|
||||
StableDiffusionSamplers,
|
||||
StableDiffusionStylePresets
|
||||
} from '@/views/ai/utils/constants'
|
||||
import { formatTime } from '@/utils'
|
||||
|
||||
const showDrawer = ref<boolean>(false) // 是否显示
|
||||
const detail = ref<ImageVO>({} as ImageVO) // 图片详细信息
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
require: true,
|
||||
default: false
|
||||
},
|
||||
id: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
/** 关闭抽屉 */
|
||||
const handleDrawerClose = async () => {
|
||||
emits('handleDrawerClose')
|
||||
}
|
||||
|
||||
/** 监听 drawer 是否打开 */
|
||||
const { show } = toRefs(props)
|
||||
watch(show, async (newValue, oldValue) => {
|
||||
showDrawer.value = newValue as boolean
|
||||
})
|
||||
|
||||
/** 获取图片详情 */
|
||||
const getImageDetail = async (id: number) => {
|
||||
detail.value = await ImageApi.getImageMy(id)
|
||||
}
|
||||
|
||||
/** 监听 id 变化,加载最新图片详情 */
|
||||
const { id } = toRefs(props)
|
||||
watch(id, async (newVal, oldVal) => {
|
||||
if (newVal) {
|
||||
await getImageDetail(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits(['handleDrawerClose'])
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.item {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 10px;
|
||||
color: #616161;
|
||||
|
||||
.taskImage {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
245
src/views/ai/image/index/components/ImageList.vue
Normal file
245
src/views/ai/image/index/components/ImageList.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<el-card class="dr-task" body-class="task-card" shadow="never">
|
||||
<template #header>
|
||||
绘画任务
|
||||
<!-- TODO @fan:看看,怎么优化下这个样子哈。 -->
|
||||
<el-button @click="handleViewPublic">绘画作品</el-button>
|
||||
</template>
|
||||
<!-- 图片列表 -->
|
||||
<div class="task-image-list" ref="imageListRef">
|
||||
<ImageCard
|
||||
v-for="image in imageList"
|
||||
:key="image.id"
|
||||
:detail="image"
|
||||
@on-btn-click="handleImageButtonClick"
|
||||
@on-mj-btn-click="handleImageMidjourneyButtonClick"
|
||||
/>
|
||||
</div>
|
||||
<div class="task-image-pagination">
|
||||
<Pagination
|
||||
:total="pageTotal"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getImageList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 图片详情 -->
|
||||
<ImageDetail
|
||||
:show="isShowImageDetail"
|
||||
:id="showImageDetailId"
|
||||
@handle-drawer-close="handleDetailClose"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ImageApi,
|
||||
ImageVO,
|
||||
ImageMidjourneyActionVO,
|
||||
ImageMidjourneyButtonsVO
|
||||
} from '@/api/ai/image'
|
||||
import ImageDetail from './ImageDetail.vue'
|
||||
import ImageCard from './ImageCard.vue'
|
||||
import { ElLoading, LoadingOptionsResolved } from 'element-plus'
|
||||
import { AiImageStatusEnum } from '@/views/ai/utils/constants'
|
||||
import download from '@/utils/download'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const router = useRouter() // 路由
|
||||
|
||||
// 图片分页相关的参数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
const pageTotal = ref<number>(0) // page size
|
||||
const imageList = ref<ImageVO[]>([]) // image 列表
|
||||
const imageListLoadingInstance = ref<any>() // image 列表是否正在加载中
|
||||
const imageListRef = ref<any>() // ref
|
||||
// 图片轮询相关的参数(正在生成中的)
|
||||
const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
|
||||
const inProgressTimer = ref<any>() // 生成中的 image 定时器,轮询生成进展
|
||||
// 图片详情相关的参数
|
||||
const isShowImageDetail = ref<boolean>(false) // 图片详情是否展示
|
||||
const showImageDetailId = ref<number>(0) // 图片详情的图片编号
|
||||
|
||||
/** 处理查看绘图作品 */
|
||||
const handleViewPublic = () => {
|
||||
router.push({
|
||||
name: 'AiImageSquare'
|
||||
})
|
||||
}
|
||||
|
||||
/** 查看图片的详情 */
|
||||
const handleDetailOpen = async () => {
|
||||
isShowImageDetail.value = true
|
||||
}
|
||||
|
||||
/** 关闭图片的详情 */
|
||||
const handleDetailClose = async () => {
|
||||
isShowImageDetail.value = false
|
||||
}
|
||||
|
||||
/** 获得 image 图片列表 */
|
||||
const getImageList = async () => {
|
||||
try {
|
||||
// 1. 加载图片列表
|
||||
imageListLoadingInstance.value = ElLoading.service({
|
||||
target: imageListRef.value,
|
||||
text: '加载中...'
|
||||
} as LoadingOptionsResolved)
|
||||
const { list, total } = await ImageApi.getImagePageMy(queryParams)
|
||||
imageList.value = list
|
||||
pageTotal.value = total
|
||||
|
||||
// 2. 计算需要轮询的图片
|
||||
const newWatImages = {}
|
||||
imageList.value.forEach((item) => {
|
||||
if (item.status === AiImageStatusEnum.IN_PROGRESS) {
|
||||
newWatImages[item.id] = item
|
||||
}
|
||||
})
|
||||
inProgressImageMap.value = newWatImages
|
||||
} finally {
|
||||
// 关闭正在“加载中”的 Loading
|
||||
if (imageListLoadingInstance.value) {
|
||||
imageListLoadingInstance.value.close()
|
||||
imageListLoadingInstance.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询生成中的 image 列表 */
|
||||
const refreshWatchImages = async () => {
|
||||
const imageIds = Object.keys(inProgressImageMap.value).map(Number)
|
||||
if (imageIds.length == 0) {
|
||||
return
|
||||
}
|
||||
const list = (await ImageApi.getImageListMyByIds(imageIds)) as ImageVO[]
|
||||
const newWatchImages = {}
|
||||
list.forEach((image) => {
|
||||
if (image.status === AiImageStatusEnum.IN_PROGRESS) {
|
||||
newWatchImages[image.id] = image
|
||||
} else {
|
||||
const index = imageList.value.findIndex((oldImage) => image.id === oldImage.id)
|
||||
if (index >= 0) {
|
||||
// 更新 imageList
|
||||
imageList.value[index] = image
|
||||
}
|
||||
}
|
||||
})
|
||||
inProgressImageMap.value = newWatchImages
|
||||
}
|
||||
|
||||
/** 图片的点击事件 */
|
||||
const handleImageButtonClick = async (type: string, imageDetail: ImageVO) => {
|
||||
// 详情
|
||||
if (type === 'more') {
|
||||
showImageDetailId.value = imageDetail.id
|
||||
await handleDetailOpen()
|
||||
return
|
||||
}
|
||||
// 删除
|
||||
if (type === 'delete') {
|
||||
await message.confirm(`是否删除照片?`)
|
||||
await ImageApi.deleteImageMy(imageDetail.id)
|
||||
await getImageList()
|
||||
message.success('删除成功!')
|
||||
return
|
||||
}
|
||||
// 下载
|
||||
if (type === 'download') {
|
||||
await download.image({ url: imageDetail.picUrl })
|
||||
return
|
||||
}
|
||||
// 重新生成
|
||||
if (type === 'regeneration') {
|
||||
await emits('onRegeneration', imageDetail)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理 Midjourney 按钮点击事件 */
|
||||
const handleImageMidjourneyButtonClick = async (
|
||||
button: ImageMidjourneyButtonsVO,
|
||||
imageDetail: ImageVO
|
||||
) => {
|
||||
// 1. 构建 params 参数
|
||||
const data = {
|
||||
id: imageDetail.id,
|
||||
customId: button.customId
|
||||
} as ImageMidjourneyActionVO
|
||||
// 2. 发送 action
|
||||
await ImageApi.midjourneyAction(data)
|
||||
// 3. 刷新列表
|
||||
await getImageList()
|
||||
}
|
||||
|
||||
defineExpose({ getImageList }) // 暴露组件方法
|
||||
|
||||
const emits = defineEmits(['onRegeneration'])
|
||||
|
||||
/** 组件挂在的时候 */
|
||||
onMounted(async () => {
|
||||
// 获取 image 列表
|
||||
await getImageList()
|
||||
// 自动刷新 image 列表
|
||||
inProgressTimer.value = setInterval(async () => {
|
||||
await refreshWatchImages()
|
||||
}, 1000 * 3)
|
||||
})
|
||||
|
||||
/** 组件取消挂在的时候 */
|
||||
onUnmounted(async () => {
|
||||
if (inProgressTimer.value) {
|
||||
clearInterval(inProgressTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.dr-task {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.task-card {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-image-list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 20px 20px 140px;
|
||||
box-sizing: border-box; /* 确保内边距不会增加高度 */
|
||||
|
||||
> div {
|
||||
margin-right: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
> div:last-of-type {
|
||||
//margin-bottom: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-image-pagination {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
height: 50px;
|
||||
line-height: 90px;
|
||||
width: 100%;
|
||||
z-index: 999;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
224
src/views/ai/image/index/components/common/index.vue
Normal file
224
src/views/ai/image/index/components/common/index.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<!-- dall3 -->
|
||||
<template>
|
||||
<div class="prompt">
|
||||
<el-text tag="b">画面描述</el-text>
|
||||
<el-text tag="p">建议使用“形容词 + 动词 + 风格”的格式,使用“,”隔开</el-text>
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
maxlength="1024"
|
||||
:rows="5"
|
||||
class="w-100% mt-15px"
|
||||
input-style="border-radius: 7px;"
|
||||
placeholder="例如:童话里的小屋应该是什么样子?"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="hot-words">
|
||||
<div>
|
||||
<el-text tag="b">随机热词</el-text>
|
||||
</div>
|
||||
<el-space wrap class="word-list">
|
||||
<el-button
|
||||
round
|
||||
class="btn"
|
||||
:type="selectHotWord === hotWord ? 'primary' : 'default'"
|
||||
v-for="hotWord in ImageHotWords"
|
||||
:key="hotWord"
|
||||
@click="handleHotWordClick(hotWord)"
|
||||
>
|
||||
{{ hotWord }}
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">平台</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-select
|
||||
v-model="otherPlatform"
|
||||
placeholder="Select"
|
||||
size="large"
|
||||
class="!w-350px"
|
||||
@change="handlerPlatformChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in OtherPlatformEnum"
|
||||
:key="item.key"
|
||||
:label="item.name"
|
||||
:value="item.key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">模型</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-select v-model="modelId" placeholder="Select" size="large" class="!w-350px">
|
||||
<el-option
|
||||
v-for="item in platformModels"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">图片尺寸</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-input v-model="width" type="number" class="w-170px" placeholder="图片宽度" />
|
||||
<el-input v-model="height" type="number" class="w-170px" placeholder="图片高度" />
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
round
|
||||
:loading="drawIn"
|
||||
:disabled="prompt.length === 0"
|
||||
@click="handleGenerateImage"
|
||||
>
|
||||
{{ drawIn ? '生成中' : '生成内容' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
|
||||
import { AiPlatformEnum, ImageHotWords, OtherPlatformEnum } from '@/views/ai/utils/constants'
|
||||
import { ModelVO } from '@/api/ai/model/model'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
// 接收父组件传入的模型列表
|
||||
const props = defineProps({
|
||||
models: {
|
||||
type: Array<ModelVO>,
|
||||
default: () => [] as ModelVO[]
|
||||
}
|
||||
})
|
||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||
|
||||
// 定义属性
|
||||
const drawIn = ref<boolean>(false) // 生成中
|
||||
const selectHotWord = ref<string>('') // 选中的热词
|
||||
// 表单
|
||||
const prompt = ref<string>('') // 提示词
|
||||
const width = ref<number>(512) // 图片宽度
|
||||
const height = ref<number>(512) // 图片高度
|
||||
const otherPlatform = ref<string>(AiPlatformEnum.TONG_YI) // 平台
|
||||
const platformModels = ref<ModelVO[]>([]) // 模型列表
|
||||
const modelId = ref<number>() // 选中的模型
|
||||
|
||||
/** 选择热词 */
|
||||
const handleHotWordClick = async (hotWord: string) => {
|
||||
// 情况一:取消选中
|
||||
if (selectHotWord.value == hotWord) {
|
||||
selectHotWord.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 情况二:选中
|
||||
selectHotWord.value = hotWord // 选中
|
||||
prompt.value = hotWord // 替换提示词
|
||||
}
|
||||
|
||||
/** 图片生成 */
|
||||
const handleGenerateImage = async () => {
|
||||
// 二次确认
|
||||
await message.confirm(`确认生成内容?`)
|
||||
try {
|
||||
// 加载中
|
||||
drawIn.value = true
|
||||
// 回调
|
||||
emits('onDrawStart', otherPlatform.value)
|
||||
// 发送请求
|
||||
const form = {
|
||||
platform: otherPlatform.value,
|
||||
modelId: modelId.value, // 模型
|
||||
prompt: prompt.value, // 提示词
|
||||
width: width.value, // 图片宽度
|
||||
height: height.value, // 图片高度
|
||||
options: {}
|
||||
} as unknown as ImageDrawReqVO
|
||||
await ImageApi.drawImage(form)
|
||||
} finally {
|
||||
// 回调
|
||||
emits('onDrawComplete', otherPlatform.value)
|
||||
// 加载结束
|
||||
drawIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 填充值 */
|
||||
const settingValues = async (detail: ImageVO) => {
|
||||
prompt.value = detail.prompt
|
||||
width.value = detail.width
|
||||
height.value = detail.height
|
||||
}
|
||||
|
||||
/** 平台切换 */
|
||||
const handlerPlatformChange = async (platform: string) => {
|
||||
// 根据选择的平台筛选模型
|
||||
platformModels.value = props.models.filter((item: ModelVO) => item.platform === platform)
|
||||
|
||||
// 切换平台,默认选择一个模型
|
||||
if (platformModels.value.length > 0) {
|
||||
modelId.value = platformModels.value[0].id // 使用 model 属性作为值
|
||||
} else {
|
||||
modelId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听 models 变化 */
|
||||
watch(
|
||||
() => props.models,
|
||||
() => {
|
||||
handlerPlatformChange(otherPlatform.value)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
/** 暴露组件方法 */
|
||||
defineExpose({ settingValues })
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.hot-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 30px;
|
||||
|
||||
.word-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start;
|
||||
margin-top: 15px;
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模型
|
||||
.group-item {
|
||||
margin-top: 30px;
|
||||
|
||||
.group-item-body {
|
||||
margin-top: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
363
src/views/ai/image/index/components/dall3/index.vue
Normal file
363
src/views/ai/image/index/components/dall3/index.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<!-- dall3 -->
|
||||
<template>
|
||||
<div class="prompt">
|
||||
<el-text tag="b">画面描述</el-text>
|
||||
<el-text tag="p">建议使用"形容词 + 动词 + 风格"的格式,使用","隔开</el-text>
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
maxlength="1024"
|
||||
:rows="5"
|
||||
class="w-100% mt-15px"
|
||||
input-style="border-radius: 7px;"
|
||||
placeholder="例如:童话里的小屋应该是什么样子?"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="hot-words">
|
||||
<div>
|
||||
<el-text tag="b">随机热词</el-text>
|
||||
</div>
|
||||
<el-space wrap class="word-list">
|
||||
<el-button
|
||||
round
|
||||
class="btn"
|
||||
:type="selectHotWord === hotWord ? 'primary' : 'default'"
|
||||
v-for="hotWord in ImageHotWords"
|
||||
:key="hotWord"
|
||||
@click="handleHotWordClick(hotWord)"
|
||||
>
|
||||
{{ hotWord }}
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="model">
|
||||
<div>
|
||||
<el-text tag="b">模型选择</el-text>
|
||||
</div>
|
||||
<el-space wrap class="model-list">
|
||||
<div
|
||||
:class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
|
||||
v-for="model in Dall3Models"
|
||||
:key="model.key"
|
||||
>
|
||||
<el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
|
||||
<div class="model-font">{{ model.name }}</div>
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="image-style">
|
||||
<div>
|
||||
<el-text tag="b">风格选择</el-text>
|
||||
</div>
|
||||
<el-space wrap class="image-style-list">
|
||||
<div
|
||||
:class="style === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'"
|
||||
v-for="imageStyle in Dall3StyleList"
|
||||
:key="imageStyle.key"
|
||||
>
|
||||
<el-image :src="imageStyle.image" fit="contain" @click="handleStyleClick(imageStyle)" />
|
||||
<div class="style-font">{{ imageStyle.name }}</div>
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="image-size">
|
||||
<div>
|
||||
<el-text tag="b">画面比例</el-text>
|
||||
</div>
|
||||
<el-space wrap class="size-list">
|
||||
<div
|
||||
class="size-item"
|
||||
v-for="imageSize in Dall3SizeList"
|
||||
:key="imageSize.key"
|
||||
@click="handleSizeClick(imageSize)"
|
||||
>
|
||||
<div
|
||||
:class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
|
||||
>
|
||||
<div :style="imageSize.style"></div>
|
||||
</div>
|
||||
<div class="size-font">{{ imageSize.name }}</div>
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
round
|
||||
:loading="drawIn"
|
||||
:disabled="prompt.length === 0"
|
||||
@click="handleGenerateImage"
|
||||
>
|
||||
{{ drawIn ? '生成中' : '生成内容' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
|
||||
import {
|
||||
Dall3Models,
|
||||
Dall3StyleList,
|
||||
ImageHotWords,
|
||||
Dall3SizeList,
|
||||
ImageModelVO,
|
||||
AiPlatformEnum,
|
||||
ImageSizeVO
|
||||
} from '@/views/ai/utils/constants'
|
||||
import { ModelVO } from '@/api/ai/model/model'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
// 接收父组件传入的模型列表
|
||||
const props = defineProps({
|
||||
models: {
|
||||
type: Array<ModelVO>,
|
||||
default: () => [] as ModelVO[]
|
||||
}
|
||||
})
|
||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||
|
||||
// 定义属性
|
||||
const prompt = ref<string>('') // 提示词
|
||||
const drawIn = ref<boolean>(false) // 生成中
|
||||
const selectHotWord = ref<string>('') // 选中的热词
|
||||
const selectModel = ref<string>('dall-e-3') // 模型
|
||||
const selectSize = ref<string>('1024x1024') // 选中 size
|
||||
const style = ref<string>('vivid') // style 样式
|
||||
|
||||
/** 选择热词 */
|
||||
const handleHotWordClick = async (hotWord: string) => {
|
||||
// 情况一:取消选中
|
||||
if (selectHotWord.value == hotWord) {
|
||||
selectHotWord.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 情况二:选中
|
||||
selectHotWord.value = hotWord
|
||||
prompt.value = hotWord
|
||||
}
|
||||
|
||||
/** 选择 model 模型 */
|
||||
const handleModelClick = async (model: ImageModelVO) => {
|
||||
selectModel.value = model.key
|
||||
// 可以在这里添加模型特定的处理逻辑
|
||||
// 例如,如果未来需要根据不同模型设置不同参数
|
||||
if (model.key === 'dall-e-3') {
|
||||
// DALL-E-3 模型特定的处理
|
||||
style.value = 'vivid' // 默认设置vivid风格
|
||||
} else if (model.key === 'dall-e-2') {
|
||||
// DALL-E-2 模型特定的处理
|
||||
style.value = 'natural' // 如果有其他DALL-E-2适合的默认风格
|
||||
}
|
||||
|
||||
// 更新其他相关参数
|
||||
// 例如可以默认选择最适合当前模型的尺寸
|
||||
const recommendedSize = Dall3SizeList.find(
|
||||
(size) =>
|
||||
(model.key === 'dall-e-3' && size.key === '1024x1024') ||
|
||||
(model.key === 'dall-e-2' && size.key === '512x512')
|
||||
)
|
||||
|
||||
if (recommendedSize) {
|
||||
selectSize.value = recommendedSize.key
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择 style 样式 */
|
||||
const handleStyleClick = async (imageStyle: ImageModelVO) => {
|
||||
style.value = imageStyle.key
|
||||
}
|
||||
|
||||
/** 选择 size 大小 */
|
||||
const handleSizeClick = async (imageSize: ImageSizeVO) => {
|
||||
selectSize.value = imageSize.key
|
||||
}
|
||||
|
||||
/** 图片生产 */
|
||||
const handleGenerateImage = async () => {
|
||||
// 从 models 中查找匹配的模型
|
||||
const matchedModel = props.models.find(
|
||||
(item) => item.model === selectModel.value && item.platform === AiPlatformEnum.OPENAI
|
||||
)
|
||||
if (!matchedModel) {
|
||||
message.error('该模型不可用,请选择其它模型')
|
||||
return
|
||||
}
|
||||
|
||||
// 二次确认
|
||||
await message.confirm(`确认生成内容?`)
|
||||
try {
|
||||
// 加载中
|
||||
drawIn.value = true
|
||||
// 回调
|
||||
emits('onDrawStart', AiPlatformEnum.OPENAI)
|
||||
const imageSize = Dall3SizeList.find((item) => item.key === selectSize.value) as ImageSizeVO
|
||||
const form = {
|
||||
platform: AiPlatformEnum.OPENAI,
|
||||
prompt: prompt.value, // 提示词
|
||||
modelId: matchedModel.id, // 使用匹配到的模型
|
||||
style: style.value, // 图像生成的风格
|
||||
width: imageSize.width, // size 不能为空
|
||||
height: imageSize.height, // size 不能为空
|
||||
options: {
|
||||
style: style.value // 图像生成的风格
|
||||
}
|
||||
} as ImageDrawReqVO
|
||||
// 发送请求
|
||||
await ImageApi.drawImage(form)
|
||||
} finally {
|
||||
// 回调
|
||||
emits('onDrawComplete', AiPlatformEnum.OPENAI)
|
||||
// 加载结束
|
||||
drawIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 填充值 */
|
||||
const settingValues = async (detail: ImageVO) => {
|
||||
prompt.value = detail.prompt
|
||||
selectModel.value = detail.model
|
||||
style.value = detail.options?.style
|
||||
const imageSize = Dall3SizeList.find(
|
||||
(item) => item.key === `${detail.width}x${detail.height}`
|
||||
) as ImageSizeVO
|
||||
await handleSizeClick(imageSize)
|
||||
}
|
||||
|
||||
/** 暴露组件方法 */
|
||||
defineExpose({ settingValues })
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
// 热词
|
||||
.hot-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 30px;
|
||||
|
||||
.word-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start;
|
||||
margin-top: 15px;
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模型
|
||||
.model {
|
||||
margin-top: 30px;
|
||||
|
||||
.model-list {
|
||||
margin-top: 15px;
|
||||
|
||||
.modal-item {
|
||||
width: 110px;
|
||||
//outline: 1px solid blue;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
.model-font {
|
||||
font-size: 14px;
|
||||
color: #3e3e3e;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.selectModel {
|
||||
border: 3px solid #1293ff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 样式 style
|
||||
.image-style {
|
||||
margin-top: 30px;
|
||||
|
||||
.image-style-list {
|
||||
margin-top: 15px;
|
||||
|
||||
.image-style-item {
|
||||
width: 110px;
|
||||
//outline: 1px solid blue;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
.style-font {
|
||||
font-size: 14px;
|
||||
color: #3e3e3e;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.selectImageStyle {
|
||||
border: 3px solid #1293ff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尺寸
|
||||
.image-size {
|
||||
width: 100%;
|
||||
margin-top: 30px;
|
||||
|
||||
.size-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
|
||||
.size-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.size-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
padding: 4px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.size-font {
|
||||
font-size: 14px;
|
||||
color: #3e3e3e;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectImageSize {
|
||||
border: 1px solid #1293ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
350
src/views/ai/image/index/components/midjourney/index.vue
Normal file
350
src/views/ai/image/index/components/midjourney/index.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<!-- dall3 -->
|
||||
<template>
|
||||
<div class="prompt">
|
||||
<el-text tag="b">画面描述</el-text>
|
||||
<el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开.</el-text>
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
maxlength="1024"
|
||||
:rows="5"
|
||||
class="w-100% mt-15px"
|
||||
input-style="border-radius: 7px;"
|
||||
placeholder="例如:童话里的小屋应该是什么样子?"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="hot-words">
|
||||
<div>
|
||||
<el-text tag="b">随机热词</el-text>
|
||||
</div>
|
||||
<el-space wrap class="word-list">
|
||||
<el-button
|
||||
round
|
||||
class="btn"
|
||||
:type="selectHotWord === hotWord ? 'primary' : 'default'"
|
||||
v-for="hotWord in ImageHotWords"
|
||||
:key="hotWord"
|
||||
@click="handleHotWordClick(hotWord)"
|
||||
>
|
||||
{{ hotWord }}
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="image-size">
|
||||
<div>
|
||||
<el-text tag="b">尺寸</el-text>
|
||||
</div>
|
||||
<el-space wrap class="size-list">
|
||||
<div
|
||||
class="size-item"
|
||||
v-for="imageSize in MidjourneySizeList"
|
||||
:key="imageSize.key"
|
||||
@click="handleSizeClick(imageSize)"
|
||||
>
|
||||
<div
|
||||
:class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
|
||||
>
|
||||
<div :style="imageSize.style"></div>
|
||||
</div>
|
||||
<div class="size-font">{{ imageSize.key }}</div>
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="model">
|
||||
<div>
|
||||
<el-text tag="b">模型</el-text>
|
||||
</div>
|
||||
<el-space wrap class="model-list">
|
||||
<div
|
||||
:class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
|
||||
v-for="model in MidjourneyModels"
|
||||
:key="model.key"
|
||||
>
|
||||
<el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
|
||||
<div class="model-font">{{ model.name }}</div>
|
||||
</div>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="version">
|
||||
<div>
|
||||
<el-text tag="b">版本</el-text>
|
||||
</div>
|
||||
<el-space wrap class="version-list">
|
||||
<el-select
|
||||
v-model="selectVersion"
|
||||
class="version-select !w-350px"
|
||||
clearable
|
||||
placeholder="请选择版本"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in versionList"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="model">
|
||||
<div>
|
||||
<el-text tag="b">参考图</el-text>
|
||||
</div>
|
||||
<el-space wrap class="model-list">
|
||||
<UploadImg v-model="referImageUrl" height="120px" width="120px" />
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
round
|
||||
:disabled="prompt.length === 0"
|
||||
@click="handleGenerateImage"
|
||||
>
|
||||
{{ drawIn ? '生成中' : '生成内容' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ImageApi, ImageMidjourneyImagineReqVO, ImageVO } from '@/api/ai/image'
|
||||
import {
|
||||
AiPlatformEnum,
|
||||
ImageHotWords,
|
||||
ImageSizeVO,
|
||||
ImageModelVO,
|
||||
MidjourneyModels,
|
||||
MidjourneySizeList,
|
||||
MidjourneyVersions,
|
||||
NijiVersionList
|
||||
} from '@/views/ai/utils/constants'
|
||||
import { ModelVO } from '@/api/ai/model/model'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
// 接收父组件传入的模型列表
|
||||
const props = defineProps({
|
||||
models: {
|
||||
type: Array<ModelVO>,
|
||||
default: () => [] as ModelVO[]
|
||||
}
|
||||
})
|
||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||
|
||||
// 定义属性
|
||||
const drawIn = ref<boolean>(false) // 生成中
|
||||
const selectHotWord = ref<string>('') // 选中的热词
|
||||
// 表单
|
||||
const prompt = ref<string>('') // 提示词
|
||||
const referImageUrl = ref<any>() // 参考图
|
||||
const selectModel = ref<string>('midjourney') // 选中的模型
|
||||
const selectSize = ref<string>('1:1') // 选中 size
|
||||
const selectVersion = ref<any>('6.0') // 选中的 version
|
||||
const versionList = ref<any>(MidjourneyVersions) // version 列表
|
||||
|
||||
/** 选择热词 */
|
||||
const handleHotWordClick = async (hotWord: string) => {
|
||||
// 情况一:取消选中
|
||||
if (selectHotWord.value == hotWord) {
|
||||
selectHotWord.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 情况二:选中
|
||||
selectHotWord.value = hotWord // 选中
|
||||
prompt.value = hotWord // 设置提示次
|
||||
}
|
||||
|
||||
/** 点击 size 尺寸 */
|
||||
const handleSizeClick = async (imageSize: ImageSizeVO) => {
|
||||
selectSize.value = imageSize.key
|
||||
}
|
||||
|
||||
/** 点击 model 模型 */
|
||||
const handleModelClick = async (model: ImageModelVO) => {
|
||||
selectModel.value = model.key
|
||||
if (model.key === 'niji') {
|
||||
versionList.value = NijiVersionList // 默认选择 niji
|
||||
} else {
|
||||
versionList.value = MidjourneyVersions // 默认选择 midjourney
|
||||
}
|
||||
selectVersion.value = versionList.value[0].value
|
||||
}
|
||||
|
||||
/** 图片生成 */
|
||||
const handleGenerateImage = async () => {
|
||||
// 从 models 中查找匹配的模型
|
||||
const matchedModel = props.models.find(
|
||||
(item) => item.model === selectModel.value && item.platform === AiPlatformEnum.MIDJOURNEY
|
||||
)
|
||||
if (!matchedModel) {
|
||||
message.error('该模型不可用,请选择其它模型')
|
||||
return
|
||||
}
|
||||
|
||||
// 二次确认
|
||||
await message.confirm(`确认生成内容?`)
|
||||
try {
|
||||
// 加载中
|
||||
drawIn.value = true
|
||||
// 回调
|
||||
emits('onDrawStart', AiPlatformEnum.MIDJOURNEY)
|
||||
// 发送请求
|
||||
const imageSize = MidjourneySizeList.find(
|
||||
(item) => selectSize.value === item.key
|
||||
) as ImageSizeVO
|
||||
const req = {
|
||||
prompt: prompt.value,
|
||||
modelId: matchedModel.id,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
version: selectVersion.value,
|
||||
referImageUrl: referImageUrl.value
|
||||
} as ImageMidjourneyImagineReqVO
|
||||
await ImageApi.midjourneyImagine(req)
|
||||
} finally {
|
||||
// 回调
|
||||
emits('onDrawComplete', AiPlatformEnum.MIDJOURNEY)
|
||||
// 加载结束
|
||||
drawIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 填充值 */
|
||||
const settingValues = async (detail: ImageVO) => {
|
||||
// 提示词
|
||||
prompt.value = detail.prompt
|
||||
// image size
|
||||
const imageSize = MidjourneySizeList.find(
|
||||
(item) => item.key === `${detail.width}:${detail.height}`
|
||||
) as ImageSizeVO
|
||||
selectSize.value = imageSize.key
|
||||
// 选中模型
|
||||
const model = MidjourneyModels.find((item) => item.key === detail.options?.model) as ImageModelVO
|
||||
await handleModelClick(model)
|
||||
// 版本
|
||||
selectVersion.value = versionList.value.find(
|
||||
(item) => item.value === detail.options?.version
|
||||
).value
|
||||
// image
|
||||
referImageUrl.value = detail.options.referImageUrl
|
||||
}
|
||||
|
||||
/** 暴露组件方法 */
|
||||
defineExpose({ settingValues })
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
// 提示词
|
||||
.prompt {
|
||||
}
|
||||
|
||||
// 热词
|
||||
.hot-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 30px;
|
||||
|
||||
.word-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start;
|
||||
margin-top: 15px;
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// version
|
||||
.version {
|
||||
margin-top: 20px;
|
||||
|
||||
.version-list {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 模型
|
||||
.model {
|
||||
margin-top: 30px;
|
||||
|
||||
.model-list {
|
||||
margin-top: 15px;
|
||||
|
||||
.modal-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 150px;
|
||||
//outline: 1px solid blue;
|
||||
overflow: hidden;
|
||||
border: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
.model-font {
|
||||
font-size: 14px;
|
||||
color: #3e3e3e;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.selectModel {
|
||||
border: 3px solid #1293ff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尺寸
|
||||
.image-size {
|
||||
width: 100%;
|
||||
margin-top: 30px;
|
||||
|
||||
.size-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
|
||||
.size-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.size-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
padding: 4px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.size-font {
|
||||
font-size: 14px;
|
||||
color: #3e3e3e;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectImageSize {
|
||||
border: 1px solid #1293ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
296
src/views/ai/image/index/components/stableDiffusion/index.vue
Normal file
296
src/views/ai/image/index/components/stableDiffusion/index.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<!-- dall3 -->
|
||||
<template>
|
||||
<div class="prompt">
|
||||
<el-text tag="b">画面描述</el-text>
|
||||
<el-text tag="p">建议使用“形容词 + 动词 + 风格”的格式,使用“,”隔开</el-text>
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
maxlength="1024"
|
||||
:rows="5"
|
||||
class="w-100% mt-15px"
|
||||
input-style="border-radius: 7px;"
|
||||
placeholder="例如:童话里的小屋应该是什么样子?"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="hot-words">
|
||||
<div>
|
||||
<el-text tag="b">随机热词</el-text>
|
||||
</div>
|
||||
<el-space wrap class="word-list">
|
||||
<el-button
|
||||
round
|
||||
class="btn"
|
||||
:type="selectHotWord === hotWord ? 'primary' : 'default'"
|
||||
v-for="hotWord in ImageHotEnglishWords"
|
||||
:key="hotWord"
|
||||
@click="handleHotWordClick(hotWord)"
|
||||
>
|
||||
{{ hotWord }}
|
||||
</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">采样方法</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-select v-model="sampler" placeholder="Select" size="large" class="!w-350px">
|
||||
<el-option
|
||||
v-for="item in StableDiffusionSamplers"
|
||||
:key="item.key"
|
||||
:label="item.name"
|
||||
:value="item.key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">CLIP</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-select v-model="clipGuidancePreset" placeholder="Select" size="large" class="!w-350px">
|
||||
<el-option
|
||||
v-for="item in StableDiffusionClipGuidancePresets"
|
||||
:key="item.key"
|
||||
:label="item.name"
|
||||
:value="item.key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">风格</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-select v-model="stylePreset" placeholder="Select" size="large" class="!w-350px">
|
||||
<el-option
|
||||
v-for="item in StableDiffusionStylePresets"
|
||||
:key="item.key"
|
||||
:label="item.name"
|
||||
:value="item.key"
|
||||
/>
|
||||
</el-select>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">图片尺寸</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-input v-model="width" class="w-170px" placeholder="图片宽度" />
|
||||
<el-input v-model="height" class="w-170px" placeholder="图片高度" />
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">迭代步数</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-input
|
||||
v-model="steps"
|
||||
type="number"
|
||||
size="large"
|
||||
class="!w-350px"
|
||||
placeholder="Please input"
|
||||
/>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">引导系数</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-input
|
||||
v-model="scale"
|
||||
type="number"
|
||||
size="large"
|
||||
class="!w-350px"
|
||||
placeholder="Please input"
|
||||
/>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="group-item">
|
||||
<div>
|
||||
<el-text tag="b">随机因子</el-text>
|
||||
</div>
|
||||
<el-space wrap class="group-item-body">
|
||||
<el-input
|
||||
v-model="seed"
|
||||
type="number"
|
||||
size="large"
|
||||
class="!w-350px"
|
||||
placeholder="Please input"
|
||||
/>
|
||||
</el-space>
|
||||
</div>
|
||||
<div class="btns">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
round
|
||||
:loading="drawIn"
|
||||
:disabled="prompt.length === 0"
|
||||
@click="handleGenerateImage"
|
||||
>
|
||||
{{ drawIn ? '生成中' : '生成内容' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
|
||||
import { hasChinese } from '@/views/ai/utils/utils'
|
||||
import {
|
||||
AiPlatformEnum,
|
||||
ImageHotEnglishWords,
|
||||
StableDiffusionClipGuidancePresets,
|
||||
StableDiffusionSamplers,
|
||||
StableDiffusionStylePresets
|
||||
} from '@/views/ai/utils/constants'
|
||||
import { ModelVO } from '@/api/ai/model/model'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
// 接收父组件传入的模型列表
|
||||
const props = defineProps({
|
||||
models: {
|
||||
type: Array<ModelVO>,
|
||||
default: () => [] as ModelVO[]
|
||||
}
|
||||
})
|
||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||
|
||||
// 定义属性
|
||||
const drawIn = ref<boolean>(false) // 生成中
|
||||
const selectHotWord = ref<string>('') // 选中的热词
|
||||
// 表单
|
||||
const prompt = ref<string>('') // 提示词
|
||||
const width = ref<number>(512) // 图片宽度
|
||||
const height = ref<number>(512) // 图片高度
|
||||
const sampler = ref<string>('DDIM') // 采样方法
|
||||
const steps = ref<number>(20) // 迭代步数
|
||||
const seed = ref<number>(42) // 控制生成图像的随机性
|
||||
const scale = ref<number>(7.5) // 引导系数
|
||||
const clipGuidancePreset = ref<string>('NONE') // 文本提示相匹配的图像(clip_guidance_preset) 简称 CLIP
|
||||
const stylePreset = ref<string>('3d-model') // 风格
|
||||
|
||||
/** 选择热词 */
|
||||
const handleHotWordClick = async (hotWord: string) => {
|
||||
// 情况一:取消选中
|
||||
if (selectHotWord.value == hotWord) {
|
||||
selectHotWord.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 情况二:选中
|
||||
selectHotWord.value = hotWord // 选中
|
||||
prompt.value = hotWord // 替换提示词
|
||||
}
|
||||
|
||||
/** 图片生成 */
|
||||
const handleGenerateImage = async () => {
|
||||
// 从 models 中查找匹配的模型
|
||||
const selectModel = 'stable-diffusion-v1-6'
|
||||
const matchedModel = props.models.find(
|
||||
(item) => item.model === selectModel && item.platform === AiPlatformEnum.STABLE_DIFFUSION
|
||||
)
|
||||
if (!matchedModel) {
|
||||
message.error('该模型不可用,请选择其它模型')
|
||||
return
|
||||
}
|
||||
|
||||
// 二次确认
|
||||
if (hasChinese(prompt.value)) {
|
||||
message.alert('暂不支持中文!')
|
||||
return
|
||||
}
|
||||
await message.confirm(`确认生成内容?`)
|
||||
|
||||
try {
|
||||
// 加载中
|
||||
drawIn.value = true
|
||||
// 回调
|
||||
emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION)
|
||||
// 发送请求
|
||||
const form = {
|
||||
modelId: matchedModel.id,
|
||||
prompt: prompt.value, // 提示词
|
||||
width: width.value, // 图片宽度
|
||||
height: height.value, // 图片高度
|
||||
options: {
|
||||
seed: seed.value, // 随机种子
|
||||
steps: steps.value, // 图片生成步数
|
||||
scale: scale.value, // 引导系数
|
||||
sampler: sampler.value, // 采样算法
|
||||
clipGuidancePreset: clipGuidancePreset.value, // 文本提示相匹配的图像 CLIP
|
||||
stylePreset: stylePreset.value // 风格
|
||||
}
|
||||
} as ImageDrawReqVO
|
||||
await ImageApi.drawImage(form)
|
||||
} finally {
|
||||
// 回调
|
||||
emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION)
|
||||
// 加载结束
|
||||
drawIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 填充值 */
|
||||
const settingValues = async (detail: ImageVO) => {
|
||||
prompt.value = detail.prompt
|
||||
width.value = detail.width
|
||||
height.value = detail.height
|
||||
seed.value = detail.options?.seed
|
||||
steps.value = detail.options?.steps
|
||||
scale.value = detail.options?.scale
|
||||
sampler.value = detail.options?.sampler
|
||||
clipGuidancePreset.value = detail.options?.clipGuidancePreset
|
||||
stylePreset.value = detail.options?.stylePreset
|
||||
}
|
||||
|
||||
/** 暴露组件方法 */
|
||||
defineExpose({ settingValues })
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
// 提示词
|
||||
.prompt {
|
||||
}
|
||||
|
||||
// 热词
|
||||
.hot-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 30px;
|
||||
|
||||
.word-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start;
|
||||
margin-top: 15px;
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模型
|
||||
.group-item {
|
||||
margin-top: 30px;
|
||||
|
||||
.group-item-body {
|
||||
margin-top: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
155
src/views/ai/image/index/index.vue
Normal file
155
src/views/ai/image/index/index.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<!-- image -->
|
||||
<template>
|
||||
<div class="ai-image">
|
||||
<div class="left">
|
||||
<div class="segmented">
|
||||
<el-segmented v-model="selectPlatform" :options="platformOptions" />
|
||||
</div>
|
||||
<div class="modal-switch-container">
|
||||
<Common
|
||||
v-if="selectPlatform === 'common'"
|
||||
ref="commonRef"
|
||||
:models="models"
|
||||
@on-draw-complete="handleDrawComplete"
|
||||
/>
|
||||
<Dall3
|
||||
v-if="selectPlatform === AiPlatformEnum.OPENAI"
|
||||
ref="dall3Ref"
|
||||
:models="models"
|
||||
@on-draw-start="handleDrawStart"
|
||||
@on-draw-complete="handleDrawComplete"
|
||||
/>
|
||||
<Midjourney
|
||||
v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY"
|
||||
ref="midjourneyRef"
|
||||
:models="models"
|
||||
/>
|
||||
<StableDiffusion
|
||||
v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
|
||||
ref="stableDiffusionRef"
|
||||
:models="models"
|
||||
@on-draw-complete="handleDrawComplete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImageList from './components/ImageList.vue'
|
||||
import { AiPlatformEnum } from '@/views/ai/utils/constants'
|
||||
import { ImageVO } from '@/api/ai/image'
|
||||
import Dall3 from './components/dall3/index.vue'
|
||||
import Midjourney from './components/midjourney/index.vue'
|
||||
import StableDiffusion from './components/stableDiffusion/index.vue'
|
||||
import Common from './components/common/index.vue'
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
import { AiModelTypeEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
const imageListRef = ref<any>() // image 列表 ref
|
||||
const dall3Ref = ref<any>() // dall3(openai) ref
|
||||
const midjourneyRef = ref<any>() // midjourney ref
|
||||
const stableDiffusionRef = ref<any>() // stable diffusion ref
|
||||
const commonRef = ref<any>() // stable diffusion ref
|
||||
|
||||
// 定义属性
|
||||
const selectPlatform = ref('common') // 选中的平台
|
||||
const platformOptions = [
|
||||
{
|
||||
label: '通用',
|
||||
value: 'common'
|
||||
},
|
||||
{
|
||||
label: 'DALL3 绘画',
|
||||
value: AiPlatformEnum.OPENAI
|
||||
},
|
||||
{
|
||||
label: 'MJ 绘画',
|
||||
value: AiPlatformEnum.MIDJOURNEY
|
||||
},
|
||||
{
|
||||
label: 'SD 绘图',
|
||||
value: AiPlatformEnum.STABLE_DIFFUSION
|
||||
}
|
||||
]
|
||||
|
||||
const models = ref<ModelVO[]>([]) // 模型列表
|
||||
|
||||
/** 绘画 start */
|
||||
const handleDrawStart = async (platform: string) => {}
|
||||
|
||||
/** 绘画 complete */
|
||||
const handleDrawComplete = async (platform: string) => {
|
||||
await imageListRef.value.getImageList()
|
||||
}
|
||||
|
||||
/** 重新生成:将画图详情填充到对应平台 */
|
||||
const handleRegeneration = async (image: ImageVO) => {
|
||||
// 切换平台
|
||||
selectPlatform.value = image.platform
|
||||
// 根据不同平台填充 image
|
||||
await nextTick()
|
||||
if (image.platform === AiPlatformEnum.MIDJOURNEY) {
|
||||
midjourneyRef.value.settingValues(image)
|
||||
} else if (image.platform === AiPlatformEnum.OPENAI) {
|
||||
dall3Ref.value.settingValues(image)
|
||||
} else if (image.platform === AiPlatformEnum.STABLE_DIFFUSION) {
|
||||
stableDiffusionRef.value.settingValues(image)
|
||||
}
|
||||
// TODO @fan:貌似 other 重新设置不行?
|
||||
}
|
||||
|
||||
/** 组件挂载的时候 */
|
||||
onMounted(async () => {
|
||||
// 获取模型列表
|
||||
models.value = await ModelApi.getModelSimpleList(AiModelTypeEnum.IMAGE)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-image {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
width: 390px;
|
||||
|
||||
.segmented .el-segmented {
|
||||
--el-border-radius-base: 16px;
|
||||
--el-segmented-item-selected-color: #fff;
|
||||
background-color: #ececec;
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
.modal-switch-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.right {
|
||||
width: 350px;
|
||||
background-color: #f7f8fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
253
src/views/ai/image/manager/index.vue
Normal file
253
src/views/ai/image/manager/index.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<doc-alert title="AI 绘图创作" url="https://doc.iocoder.cn/ai/image/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-select
|
||||
v-model="queryParams.userId"
|
||||
clearable
|
||||
placeholder="请输入用户编号"
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in userList"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台" prop="platform">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择平台" clearable class="!w-240px">
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="绘画状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择绘画状态"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.AI_IMAGE_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否发布" prop="publicStatus">
|
||||
<el-select
|
||||
v-model="queryParams.publicStatus"
|
||||
placeholder="请选择是否发布"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
|
||||
<el-table-column label="图片" align="center" prop="picUrl" width="110px" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
class="h-80px w-80px"
|
||||
lazy
|
||||
:src="row.picUrl"
|
||||
:preview-src-list="[row.picUrl]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
v-if="row.picUrl?.length > 0"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" align="center" prop="userId" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" align="center" prop="platform" width="120">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型" align="center" prop="model" width="180" />
|
||||
<el-table-column label="绘画状态" align="center" prop="status" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_IMAGE_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否发布" align="center" prop="publicStatus">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.publicStatus"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
@change="handleUpdatePublicStatusChange(scope.row)"
|
||||
:disabled="scope.row.status !== AiImageStatusEnum.SUCCESS"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提示词" align="center" prop="prompt" width="180" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="宽度" align="center" prop="width" />
|
||||
<el-table-column label="高度" align="center" prop="height" />
|
||||
<el-table-column label="错误信息" align="center" prop="errorMessage" />
|
||||
<el-table-column label="任务编号" align="center" prop="taskId" />
|
||||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:image:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE, getStrDictOptions, getBoolDictOptions } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { ImageApi, ImageVO } from '@/api/ai/image'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import { AiImageStatusEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
/** AI 绘画 列表 */
|
||||
defineOptions({ name: 'AiImageManager' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ImageVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
platform: undefined,
|
||||
status: undefined,
|
||||
publicStatus: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const userList = ref<UserApi.UserVO[]>([]) // 用户列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ImageApi.getImagePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ImageApi.deleteImage(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 修改是否发布 */
|
||||
const handleUpdatePublicStatusChange = async (row: ImageVO) => {
|
||||
try {
|
||||
// 修改状态的二次确认
|
||||
const text = row.publicStatus ? '公开' : '私有'
|
||||
await message.confirm('确认要"' + text + '"该图片吗?')
|
||||
// 发起修改状态
|
||||
await ImageApi.updateImage({
|
||||
id: row.id,
|
||||
publicStatus: row.publicStatus
|
||||
})
|
||||
await getList()
|
||||
} catch {
|
||||
row.publicStatus = !row.publicStatus
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
getList()
|
||||
// 获得用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
})
|
||||
</script>
|
||||
104
src/views/ai/image/square/index.vue
Normal file
104
src/views/ai/image/square/index.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="square-container">
|
||||
<!-- TODO @fan:style 建议换成 unocss -->
|
||||
<!-- TODO @fan:Search 可以换成 Icon 组件么? -->
|
||||
<el-input
|
||||
v-model="queryParams.prompt"
|
||||
style="width: 100%; margin-bottom: 20px"
|
||||
size="large"
|
||||
placeholder="请输入要搜索的内容"
|
||||
:suffix-icon="Search"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
<div class="gallery">
|
||||
<!-- TODO @fan:这个图片的风格,要不和 ImageCard.vue 界面一致?(只有卡片,没有操作);因为看着更有相框的感觉~~~ -->
|
||||
<div v-for="item in list" :key="item.id" class="gallery-item">
|
||||
<img :src="item.picUrl" class="img" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO @fan:缺少翻页 -->
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ImageApi, ImageVO } from '@/api/ai/image'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
|
||||
// TODO @fan:加个 loading 加载中的状态
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ImageVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
publicStatus: true,
|
||||
prompt: undefined
|
||||
})
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ImageApi.getImagePageMy(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await getList()
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.square-container {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
//max-width: 1000px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.gallery-item:hover img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
src/views/ai/knowledge/document/form/ProcessStep.vue
Normal file
146
src/views/ai/knowledge/document/form/ProcessStep.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 文件处理列表 -->
|
||||
<div class="mt-15px grid grid-cols-1 gap-2">
|
||||
<div
|
||||
v-for="(file, index) in modelValue.list"
|
||||
:key="index"
|
||||
class="flex items-center py-4px px-12px border-l-4 border-l-[#409eff] rounded-sm shadow-sm hover:bg-[#ecf5ff] transition-all duration-300"
|
||||
>
|
||||
<!-- 文件图标和名称 -->
|
||||
<div class="flex items-center min-w-[200px] mr-10px">
|
||||
<Icon icon="ep:document" class="mr-8px text-[#409eff]" />
|
||||
<span class="text-[13px] text-[#303133] break-all">{{ file.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 处理进度 -->
|
||||
<div class="flex-1">
|
||||
<el-progress
|
||||
:percentage="file.progress || 0"
|
||||
:stroke-width="10"
|
||||
:status="isProcessComplete(file) ? 'success' : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分段数量 -->
|
||||
<div class="ml-10px text-[13px] text-[#606266]">
|
||||
分段数量:{{ file.count ? file.count : '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部完成按钮 -->
|
||||
<div class="flex justify-end mt-20px">
|
||||
<el-button
|
||||
:type="allProcessComplete ? 'success' : 'primary'"
|
||||
:disabled="!allProcessComplete"
|
||||
@click="handleComplete"
|
||||
>
|
||||
完成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { KnowledgeSegmentApi } from '@/api/ai/knowledge/segment'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const parent = inject('parent') as any
|
||||
const pollingTimer = ref<number | null>(null) // 轮询定时器 ID,用于跟踪和清除轮询进程
|
||||
|
||||
/** 判断文件处理是否完成 */
|
||||
const isProcessComplete = (file) => {
|
||||
return file.progress === 100
|
||||
}
|
||||
|
||||
/** 判断所有文件是否都处理完成 */
|
||||
const allProcessComplete = computed(() => {
|
||||
return props.modelValue.list.every((file) => isProcessComplete(file))
|
||||
})
|
||||
|
||||
/** 完成按钮点击事件处理 */
|
||||
const handleComplete = () => {
|
||||
if (parent?.exposed?.handleBack) {
|
||||
parent.exposed.handleBack()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取文件处理进度 */
|
||||
const getProcessList = async () => {
|
||||
try {
|
||||
// 1. 调用 API 获取处理进度
|
||||
const documentIds = props.modelValue.list.filter((item) => item.id).map((item) => item.id)
|
||||
if (documentIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const result = await KnowledgeSegmentApi.getKnowledgeSegmentProcessList(documentIds)
|
||||
|
||||
// 2.1更新进度
|
||||
const updatedList = props.modelValue.list.map((file) => {
|
||||
const processInfo = result.find((item) => item.documentId === file.id)
|
||||
if (processInfo) {
|
||||
// 计算进度百分比:已嵌入数量 / 总数量 * 100
|
||||
const progress =
|
||||
processInfo.embeddingCount && processInfo.count
|
||||
? Math.floor((processInfo.embeddingCount / processInfo.count) * 100)
|
||||
: 0
|
||||
return {
|
||||
...file,
|
||||
progress: progress,
|
||||
count: processInfo.count || 0
|
||||
}
|
||||
}
|
||||
return file
|
||||
})
|
||||
|
||||
// 2.2 更新数据
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
list: updatedList
|
||||
})
|
||||
|
||||
// 3. 如果未完成,继续轮询
|
||||
if (!updatedList.every((file) => isProcessComplete(file))) {
|
||||
pollingTimer.value = window.setTimeout(getProcessList, 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
// 出错后也继续轮询
|
||||
console.error('获取处理进度失败:', error)
|
||||
pollingTimer.value = window.setTimeout(getProcessList, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/** 组件挂载时开始轮询 */
|
||||
onMounted(() => {
|
||||
// 1. 初始化进度为 0
|
||||
const initialList = props.modelValue.list.map((file) => ({
|
||||
...file,
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
list: initialList
|
||||
})
|
||||
|
||||
// 2. 开始轮询获取进度
|
||||
getProcessList()
|
||||
})
|
||||
|
||||
/** 组件卸载前清除轮询 */
|
||||
onBeforeUnmount(() => {
|
||||
// 1. 清除定时器
|
||||
if (pollingTimer.value) {
|
||||
clearTimeout(pollingTimer.value)
|
||||
pollingTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
238
src/views/ai/knowledge/document/form/SplitStep.vue
Normal file
238
src/views/ai/knowledge/document/form/SplitStep.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 上部分段设置部分 -->
|
||||
<div class="mb-20px">
|
||||
<div class="mb-20px flex justify-between items-center">
|
||||
<div class="text-16px font-bold flex items-center">
|
||||
分段设置
|
||||
<el-tooltip
|
||||
content="系统会自动将文档内容分割成多个段落,您可以根据需要调整分段方式和内容。"
|
||||
placement="top"
|
||||
>
|
||||
<Icon icon="ep:warning" class="ml-5px text-gray-400" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="primary" plain size="small" @click="handleAutoSegment">
|
||||
预览分段
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="segment-settings mb-20px">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="最大 Token 数">
|
||||
<el-input-number v-model="modelData.segmentMaxTokens" :min="1" :max="2048" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下部文件预览部分 -->
|
||||
<div class="mb-10px">
|
||||
<div class="text-16px font-bold mb-10px">分段预览</div>
|
||||
|
||||
<!-- 文件选择器 -->
|
||||
<div class="file-selector mb-10px">
|
||||
<el-dropdown v-if="modelData.list && modelData.list.length > 0" trigger="click">
|
||||
<div class="flex items-center cursor-pointer">
|
||||
<Icon icon="ep:document" class="text-danger mr-5px" />
|
||||
<span>{{ currentFile?.name || '请选择文件' }}</span>
|
||||
<span v-if="currentFile?.segments" class="ml-5px text-gray-500 text-12px">
|
||||
({{ currentFile.segments.length }}个分片)
|
||||
</span>
|
||||
<Icon icon="ep:arrow-down" class="ml-5px" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="(file, index) in modelData.list"
|
||||
:key="index"
|
||||
@click="selectFile(index)"
|
||||
>
|
||||
{{ file.name }}
|
||||
<span v-if="file.segments" class="ml-5px text-gray-500 text-12px">
|
||||
({{ file.segments.length }}个分片)
|
||||
</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<div v-else class="text-gray-400">暂无上传文件</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件内容预览 -->
|
||||
<div class="file-preview bg-gray-50 p-15px rounded-md max-h-600px overflow-y-auto">
|
||||
<div v-if="splitLoading" class="flex justify-center items-center py-20px">
|
||||
<Icon icon="ep:loading" class="is-loading" />
|
||||
<span class="ml-10px">正在加载分段内容...</span>
|
||||
</div>
|
||||
<template
|
||||
v-else-if="currentFile && currentFile.segments && currentFile.segments.length > 0"
|
||||
>
|
||||
<div v-for="(segment, index) in currentFile.segments" :key="index" class="mb-10px">
|
||||
<div class="text-gray-500 text-12px mb-5px">
|
||||
分片-{{ index + 1 }} · {{ segment.contentLength || 0 }} 字符数 ·
|
||||
{{ segment.tokens || 0 }} Token
|
||||
</div>
|
||||
<div class="bg-white p-10px rounded-md">{{ segment.content }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="暂无预览内容" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加底部按钮 -->
|
||||
<div class="mt-20px flex justify-between">
|
||||
<div>
|
||||
<el-button v-if="!modelData.id" @click="handlePrevStep">上一步</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSave">
|
||||
保存并处理
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, getCurrentInstance, inject, onMounted, PropType, ref } from 'vue'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { KnowledgeSegmentApi } from '@/api/ai/knowledge/segment'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { KnowledgeDocumentApi } from '@/api/ai/knowledge/document'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<any>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const message = useMessage() // 消息提示
|
||||
const parent = inject('parent', null) // 获取父组件实例
|
||||
|
||||
const modelData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
}) // 表单数据
|
||||
|
||||
const splitLoading = ref(false) // 分段加载状态
|
||||
const currentFile = ref<any>(null) // 当前选中的文件
|
||||
const submitLoading = ref(false) // 提交按钮加载状态
|
||||
|
||||
/** 选择文件 */
|
||||
const selectFile = async (index: number) => {
|
||||
currentFile.value = modelData.value.list[index]
|
||||
await splitContent(currentFile.value)
|
||||
}
|
||||
|
||||
/** 获取文件分段内容 */
|
||||
const splitContent = async (file: any) => {
|
||||
if (!file || !file.url) {
|
||||
message.warning('文件 URL 不存在')
|
||||
return
|
||||
}
|
||||
|
||||
splitLoading.value = true
|
||||
try {
|
||||
// 调用后端分段接口,获取文档的分段内容、字符数和 Token 数
|
||||
file.segments = await KnowledgeSegmentApi.splitContent(
|
||||
file.url,
|
||||
modelData.value.segmentMaxTokens
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('获取分段内容失败:', file, error)
|
||||
} finally {
|
||||
splitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理预览分段 */
|
||||
const handleAutoSegment = async () => {
|
||||
// 如果没有选中文件,默认选中第一个
|
||||
if (!currentFile.value && modelData.value.list && modelData.value.list.length > 0) {
|
||||
currentFile.value = modelData.value.list[0]
|
||||
}
|
||||
// 如果没有选中文件,提示请先选择文件
|
||||
if (!currentFile.value) {
|
||||
message.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分段内容
|
||||
await splitContent(currentFile.value)
|
||||
}
|
||||
|
||||
/** 上一步按钮处理 */
|
||||
const handlePrevStep = () => {
|
||||
const parentEl = parent || getCurrentInstance()?.parent
|
||||
if (parentEl && typeof parentEl.exposed?.goToPrevStep === 'function') {
|
||||
parentEl.exposed.goToPrevStep()
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存操作 */
|
||||
const handleSave = async () => {
|
||||
// 保存前验证
|
||||
if (!currentFile?.value?.segments || currentFile.value.segments.length === 0) {
|
||||
message.warning('请先预览分段内容')
|
||||
return
|
||||
}
|
||||
|
||||
// 设置按钮加载状态
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (modelData.value.id) {
|
||||
// 修改场景
|
||||
await KnowledgeDocumentApi.updateKnowledgeDocument({
|
||||
id: modelData.value.id,
|
||||
segmentMaxTokens: modelData.value.segmentMaxTokens
|
||||
})
|
||||
} else {
|
||||
// 新增场景
|
||||
const data = await KnowledgeDocumentApi.createKnowledgeDocumentList({
|
||||
knowledgeId: modelData.value.knowledgeId,
|
||||
segmentMaxTokens: modelData.value.segmentMaxTokens,
|
||||
list: modelData.value.list.map((item: any) => ({
|
||||
name: item.name,
|
||||
url: item.url
|
||||
}))
|
||||
})
|
||||
modelData.value.list.forEach((document: any, index: number) => {
|
||||
document.id = data[index]
|
||||
})
|
||||
}
|
||||
|
||||
// 进入下一步
|
||||
const parentEl = parent || getCurrentInstance()?.parent
|
||||
if (parentEl && typeof parentEl.exposed?.goToNextStep === 'function') {
|
||||
parentEl.exposed.goToNextStep()
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', modelData.value, error)
|
||||
} finally {
|
||||
// 关闭按钮加载状态
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
// 确保 segmentMaxTokens 存在
|
||||
if (!modelData.value.segmentMaxTokens) {
|
||||
modelData.value.segmentMaxTokens = 500
|
||||
}
|
||||
// 如果没有选中文件,默认选中第一个
|
||||
if (!currentFile.value && modelData.value.list && modelData.value.list.length > 0) {
|
||||
currentFile.value = modelData.value.list[0]
|
||||
}
|
||||
|
||||
// 如果有选中的文件,获取分段内容
|
||||
if (currentFile.value) {
|
||||
await splitContent(currentFile.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
273
src/views/ai/knowledge/document/form/UploadStep.vue
Normal file
273
src/views/ai/knowledge/document/form/UploadStep.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :model="modelData" label-width="0" class="mt-20px">
|
||||
<el-form-item class="mb-20px">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="w-full border-2 border-dashed border-[#dcdfe6] rounded-md p-20px text-center hover:border-[#409eff]"
|
||||
>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="upload-demo"
|
||||
drag
|
||||
:action="uploadUrl"
|
||||
:auto-upload="true"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
:on-change="handleFileChange"
|
||||
:on-remove="handleFileRemove"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="httpRequest"
|
||||
:file-list="fileList"
|
||||
:multiple="true"
|
||||
:show-file-list="false"
|
||||
:accept="acceptedFileTypes"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center py-20px">
|
||||
<Icon icon="ep:upload-filled" class="text-[48px] text-[#c0c4cc] mb-10px" />
|
||||
<div class="el-upload__text text-[16px] text-[#606266]">
|
||||
拖拽文件至此,或者
|
||||
<em class="text-[#409eff] not-italic cursor-pointer">选择文件</em>
|
||||
</div>
|
||||
<div class="el-upload__tip mt-10px text-[#909399] text-[12px]">
|
||||
已支持 {{ supportedFileTypes.join('、') }},每个文件不超过 {{ maxFileSize }} MB。
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="modelData.list && modelData.list.length > 0"
|
||||
class="mt-15px grid grid-cols-1 gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="(file, index) in modelData.list"
|
||||
:key="index"
|
||||
class="flex justify-between items-center py-4px px-12px border-l-4 border-l-[#409eff] rounded-sm shadow-sm hover:bg-[#ecf5ff] transition-all duration-300"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon icon="ep:document" class="mr-8px text-[#409eff]" />
|
||||
<span class="text-[13px] text-[#303133] break-all">{{ file.name }}</span>
|
||||
</div>
|
||||
<el-button type="danger" link @click="removeFile(index)" class="ml-2">
|
||||
<Icon icon="ep:delete" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 添加下一步按钮 -->
|
||||
<el-form-item>
|
||||
<div class="flex justify-end w-full">
|
||||
<el-button type="primary" @click="handleNextStep" :disabled="!isAllUploaded">
|
||||
下一步
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType, ref, computed, inject, getCurrentInstance, onMounted } from 'vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { useUpload } from '@/components/UploadFile/src/useUpload'
|
||||
import { generateAcceptedFileTypes } from '@/utils'
|
||||
import { Icon } from '@/components/Icon'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as PropType<any>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const formRef = ref() // 表单引用
|
||||
const uploadRef = ref() // 上传组件引用
|
||||
const parent = inject('parent', null) // 获取父组件实例
|
||||
const { uploadUrl, httpRequest } = useUpload() // 使用上传组件的钩子
|
||||
const message = useMessage() // 消息弹窗
|
||||
const fileList = ref([]) // 文件列表
|
||||
const uploadingCount = ref(0) // 上传中的文件数量
|
||||
|
||||
// 支持的文件类型和大小限制
|
||||
const supportedFileTypes = [
|
||||
'TXT',
|
||||
'MARKDOWN',
|
||||
'MDX',
|
||||
'PDF',
|
||||
'HTML',
|
||||
'XLSX',
|
||||
'XLS',
|
||||
'DOC',
|
||||
'DOCX',
|
||||
'CSV',
|
||||
'EML',
|
||||
'MSG',
|
||||
'PPTX',
|
||||
'XML',
|
||||
'EPUB',
|
||||
'PPT',
|
||||
'MD',
|
||||
'HTM'
|
||||
]
|
||||
const allowedExtensions = supportedFileTypes.map((ext) => ext.toLowerCase()) // 小写的扩展名列表
|
||||
const maxFileSize = 15 // 最大文件大小(MB)
|
||||
|
||||
// 构建 accept 属性值,用于限制文件选择对话框中可见的文件类型
|
||||
const acceptedFileTypes = computed(() => generateAcceptedFileTypes(supportedFileTypes))
|
||||
|
||||
/** 表单数据 */
|
||||
const modelData = computed({
|
||||
get: () => {
|
||||
return props.modelValue
|
||||
},
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
/** 确保 list 属性存在 */
|
||||
const ensureListExists = () => {
|
||||
if (!props.modelValue.list) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
list: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否所有文件都已上传完成 */
|
||||
const isAllUploaded = computed(() => {
|
||||
return modelData.value.list && modelData.value.list.length > 0 && uploadingCount.value === 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传前检查文件类型和大小
|
||||
*
|
||||
* @param file 待上传的文件
|
||||
* @returns 是否允许上传
|
||||
*/
|
||||
const beforeUpload = (file) => {
|
||||
// 1.1 检查文件扩展名
|
||||
const fileName = file.name.toLowerCase()
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1)
|
||||
if (!allowedExtensions.includes(fileExtension)) {
|
||||
message.error('不支持的文件类型!')
|
||||
return false
|
||||
}
|
||||
// 1.2 检查文件大小
|
||||
if (!(file.size / 1024 / 1024 < maxFileSize)) {
|
||||
message.error(`文件大小不能超过 ${maxFileSize} MB!`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. 增加上传中的文件计数
|
||||
uploadingCount.value++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传成功处理
|
||||
*
|
||||
* @param response 上传响应
|
||||
* @param file 上传的文件
|
||||
*/
|
||||
const handleUploadSuccess = (response, file) => {
|
||||
// 添加到文件列表
|
||||
if (response && response.data) {
|
||||
ensureListExists()
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
list: [
|
||||
...props.modelValue.list,
|
||||
{
|
||||
name: file.name,
|
||||
url: response.data
|
||||
}
|
||||
]
|
||||
})
|
||||
} else {
|
||||
message.error(`文件 ${file.name} 上传失败`)
|
||||
}
|
||||
|
||||
// 减少上传中的文件计数
|
||||
uploadingCount.value = Math.max(0, uploadingCount.value - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传失败处理
|
||||
*
|
||||
* @param error 错误信息
|
||||
* @param file 上传的文件
|
||||
*/
|
||||
const handleUploadError = (error, file) => {
|
||||
message.error(`文件 ${file.name} 上传失败: ${error}`)
|
||||
// 减少上传中的文件计数
|
||||
uploadingCount.value = Math.max(0, uploadingCount.value - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件变更处理
|
||||
*
|
||||
* @param file 变更的文件
|
||||
*/
|
||||
const handleFileChange = (file) => {
|
||||
if (file.status === 'success' || file.status === 'fail') {
|
||||
uploadingCount.value = Math.max(0, uploadingCount.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件移除处理
|
||||
*
|
||||
* @param file 被移除的文件
|
||||
*/
|
||||
const handleFileRemove = (file) => {
|
||||
if (file.status === 'uploading') {
|
||||
uploadingCount.value = Math.max(0, uploadingCount.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从列表中移除文件
|
||||
*
|
||||
* @param index 要移除的文件索引
|
||||
*/
|
||||
const removeFile = (index: number) => {
|
||||
// 从列表中移除文件
|
||||
const newList = [...props.modelValue.list]
|
||||
newList.splice(index, 1)
|
||||
// 更新表单数据
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
list: newList
|
||||
})
|
||||
}
|
||||
|
||||
/** 下一步按钮处理 */
|
||||
const handleNextStep = () => {
|
||||
// 1.1 检查是否有文件上传
|
||||
if (!modelData.value.list || modelData.value.list.length === 0) {
|
||||
message.warning('请上传至少一个文件')
|
||||
return
|
||||
}
|
||||
// 1.2 检查是否有文件正在上传
|
||||
if (uploadingCount.value > 0) {
|
||||
message.warning('请等待所有文件上传完成')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取父组件的goToNextStep方法
|
||||
const parentEl = parent || getCurrentInstance()?.parent
|
||||
if (parentEl && typeof parentEl.exposed?.goToNextStep === 'function') {
|
||||
parentEl.exposed.goToNextStep()
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
ensureListExists()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
193
src/views/ai/knowledge/document/form/index.vue
Normal file
193
src/views/ai/knowledge/document/form/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<div class="mx-auto">
|
||||
<!-- 头部导航栏 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 h-50px bg-white border-bottom z-10 flex items-center px-20px"
|
||||
>
|
||||
<!-- 左侧标题 -->
|
||||
<div class="w-200px flex items-center overflow-hidden">
|
||||
<Icon icon="ep:arrow-left" class="cursor-pointer flex-shrink-0" @click="handleBack" />
|
||||
<span class="ml-10px text-16px truncate">
|
||||
{{ formData.id ? '编辑知识库文档' : '创建知识库文档' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 步骤条 -->
|
||||
<div class="flex-1 flex items-center justify-center h-full">
|
||||
<div class="w-400px flex items-center justify-between h-full">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center mx-15px relative h-full"
|
||||
:class="[
|
||||
currentStep === index
|
||||
? 'text-[#3473ff] border-[#3473ff] border-b-2 border-b-solid'
|
||||
: 'text-gray-500'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="w-28px h-28px rounded-full flex items-center justify-center mr-8px border-2 border-solid text-15px"
|
||||
:class="[
|
||||
currentStep === index
|
||||
? 'bg-[#3473ff] text-white border-[#3473ff]'
|
||||
: 'border-gray-300 bg-white text-gray-500'
|
||||
]"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span class="text-16px font-bold whitespace-nowrap">{{ step.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧按钮 - 已移除 -->
|
||||
<div class="w-200px flex items-center justify-end gap-2"> </div>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="mt-50px">
|
||||
<!-- 第一步:上传文档 -->
|
||||
<div v-if="currentStep === 0" class="mx-auto w-560px">
|
||||
<UploadStep v-model="formData" ref="uploadDocumentRef" />
|
||||
</div>
|
||||
|
||||
<!-- 第二步:文档分段 -->
|
||||
<div v-if="currentStep === 1" class="mx-auto w-560px">
|
||||
<SplitStep v-model="formData" ref="documentSegmentRef" />
|
||||
</div>
|
||||
|
||||
<!-- 第三步:处理并完成 -->
|
||||
<div v-if="currentStep === 2" class="mx-auto w-560px">
|
||||
<ProcessStep v-model="formData" ref="processCompleteRef" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
import UploadStep from './UploadStep.vue'
|
||||
import SplitStep from './SplitStep.vue'
|
||||
import ProcessStep from './ProcessStep.vue'
|
||||
import { KnowledgeDocumentApi } from '@/api/ai/knowledge/document'
|
||||
|
||||
const { delView } = useTagsViewStore() // 视图操作
|
||||
const route = useRoute() // 路由
|
||||
const router = useRouter() // 路由
|
||||
|
||||
// 组件引用
|
||||
const uploadDocumentRef = ref()
|
||||
const documentSegmentRef = ref()
|
||||
const processCompleteRef = ref()
|
||||
const currentStep = ref(0) // 步骤控制
|
||||
const steps = [{ title: '上传文档' }, { title: '文档分段' }, { title: '处理并完成' }]
|
||||
const formData = ref({
|
||||
knowledgeId: undefined, // 知识库编号
|
||||
id: undefined, // 编辑的文档编号(documentId)
|
||||
segmentMaxTokens: 500, // 分段最大 token 数
|
||||
list: [] as Array<{
|
||||
id: number // 文档编号
|
||||
name: string // 文档名称
|
||||
url: string // 文档 URL
|
||||
segments: Array<{
|
||||
content?: string
|
||||
contentLength?: number
|
||||
tokens?: number
|
||||
}>
|
||||
count?: number // 段落数量
|
||||
process?: number // 处理进度
|
||||
}> // 用于存储上传的文件列表
|
||||
}) // 表单数据
|
||||
|
||||
provide('parent', getCurrentInstance()) // 提供 parent 给子组件使用
|
||||
|
||||
/** 初始化数据 */
|
||||
const initData = async () => {
|
||||
// 【新增场景】从路由参数中获取知识库 ID
|
||||
if (route.query.knowledgeId) {
|
||||
formData.value.knowledgeId = route.query.knowledgeId as any
|
||||
}
|
||||
|
||||
// 【修改场景】从路由参数中获取文档 ID
|
||||
const documentId = route.query.id
|
||||
if (documentId) {
|
||||
// 获取文档信息
|
||||
formData.value.id = documentId as any
|
||||
const document = await KnowledgeDocumentApi.getKnowledgeDocument(documentId as any)
|
||||
formData.value.segmentMaxTokens = document.segmentMaxTokens
|
||||
formData.value.list = [
|
||||
{
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
url: document.url,
|
||||
segments: []
|
||||
}
|
||||
]
|
||||
// 进入下一步
|
||||
goToNextStep()
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换到下一步 */
|
||||
const goToNextStep = () => {
|
||||
if (currentStep.value < steps.length - 1) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换到上一步 */
|
||||
const goToPrevStep = () => {
|
||||
if (currentStep.value > 0) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回列表页 */
|
||||
const handleBack = () => {
|
||||
// 先删除当前页签
|
||||
delView(unref(router.currentRoute))
|
||||
// 跳转到列表页
|
||||
router.push({ name: 'AiKnowledgeDocument', query: { knowledgeId: formData.value.knowledgeId } })
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await initData()
|
||||
})
|
||||
|
||||
/** 添加组件卸载前的清理代码 */
|
||||
onBeforeUnmount(() => {
|
||||
// 清理所有的引用
|
||||
uploadDocumentRef.value = null
|
||||
documentSegmentRef.value = null
|
||||
processCompleteRef.value = null
|
||||
})
|
||||
|
||||
/** 暴露方法给子组件使用 */
|
||||
defineExpose({
|
||||
goToNextStep,
|
||||
goToPrevStep,
|
||||
handleBack
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #3473ff;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: #3473ff;
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: #3473ff;
|
||||
}
|
||||
</style>
|
||||
236
src/views/ai/knowledge/document/index.vue
Normal file
236
src/views/ai/knowledge/document/index.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="文件名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入文件名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择是否启用"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button type="primary" plain @click="handleCreate" v-hasPermi="['ai:knowledge:create']">
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="文档编号" align="center" prop="id" />
|
||||
<el-table-column label="文件名称" align="center" prop="name" />
|
||||
<el-table-column label="字符数" align="center" prop="contentLength" />
|
||||
<el-table-column label="Token 数" align="center" prop="tokens" />
|
||||
<el-table-column label="分片最大 Token 数" align="center" prop="segmentMaxTokens" />
|
||||
<el-table-column label="召回次数" align="center" prop="retrievalCount" />
|
||||
<el-table-column label="是否启用" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
:disabled="!checkPermi(['ai:knowledge:update'])"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="上传时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" min-width="120px">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleUpdate(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleSegment(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:query']"
|
||||
>
|
||||
分段
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<!-- <KnowledgeDocumentForm ref="formRef" @success="getList" /> -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { KnowledgeDocumentApi, KnowledgeDocumentVO } from '@/api/ai/knowledge/document'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { checkPermi } from '@/utils/permission'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
// import KnowledgeDocumentForm from './KnowledgeDocumentForm.vue'
|
||||
|
||||
/** AI 知识库文档 列表 */
|
||||
defineOptions({ name: 'KnowledgeDocument' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
const route = useRoute() // 路由
|
||||
const router = useRouter() // 路由
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<KnowledgeDocumentVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
knowledgeId: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await KnowledgeDocumentApi.getKnowledgeDocumentPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 跳转到创建文档页面 */
|
||||
const handleCreate = () => {
|
||||
router.push({
|
||||
name: 'AiKnowledgeDocumentCreate',
|
||||
query: { knowledgeId: queryParams.knowledgeId }
|
||||
})
|
||||
}
|
||||
|
||||
/** 跳转到更新文档页面 */
|
||||
const handleUpdate = (id: number) => {
|
||||
router.push({
|
||||
name: 'AiKnowledgeDocumentUpdate',
|
||||
query: { id, knowledgeId: queryParams.knowledgeId }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await KnowledgeDocumentApi.deleteKnowledgeDocument(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 修改状态操作 */
|
||||
const handleStatusChange = async (row: KnowledgeDocumentVO) => {
|
||||
try {
|
||||
// 修改状态的二次确认
|
||||
const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '禁用'
|
||||
await message.confirm('确认要"' + text + '""' + row.name + '"文档吗?')
|
||||
// 发起修改状态
|
||||
await KnowledgeDocumentApi.updateKnowledgeDocumentStatus({ id: row.id, status: row.status })
|
||||
message.success(t('common.updateSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {
|
||||
// 取消后,进行恢复按钮
|
||||
row.status =
|
||||
row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转到知识库分段页面 */
|
||||
const handleSegment = (id: number) => {
|
||||
router.push({
|
||||
name: 'AiKnowledgeSegment',
|
||||
query: { documentId: id }
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
// 如果知识库 ID 不存在,显示错误提示并关闭页面
|
||||
if (!route.query.knowledgeId) {
|
||||
message.error('知识库 ID 不存在,无法查看文档列表')
|
||||
// 关闭当前路由,返回到知识库列表页面
|
||||
router.push({ name: 'AiKnowledge' })
|
||||
return
|
||||
}
|
||||
|
||||
// 从路由参数中获取知识库 ID
|
||||
queryParams.knowledgeId = route.query.knowledgeId as any
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
162
src/views/ai/knowledge/knowledge/KnowledgeForm.vue
Normal file
162
src/views/ai/knowledge/knowledge/KnowledgeForm.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="130px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="知识库名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入知识库名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="知识库描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入知识库描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="向量模型" prop="embeddingModelId">
|
||||
<el-select
|
||||
v-model="formData.embeddingModelId"
|
||||
placeholder="请选择向量模型"
|
||||
clearable
|
||||
class="!w-full"
|
||||
>
|
||||
<el-option v-for="item in modelList" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="检索 topK" prop="topK">
|
||||
<el-input-number
|
||||
v-model="formData.topK"
|
||||
placeholder="请输入检索 topK"
|
||||
:min="0"
|
||||
:max="10"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="检索相似度阈值" prop="similarityThreshold">
|
||||
<el-input-number
|
||||
v-model="formData.similarityThreshold"
|
||||
placeholder="请输入检索相似度阈值"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { KnowledgeApi, KnowledgeVO } from '@/api/ai/knowledge/knowledge'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
import { AiModelTypeEnum } from '../../utils/constants'
|
||||
|
||||
/** AI 知识库表单 */
|
||||
defineOptions({ name: 'KnowledgeForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
embeddingModelId: undefined,
|
||||
topK: undefined,
|
||||
similarityThreshold: undefined,
|
||||
status: CommonStatusEnum.ENABLE // 默认开启
|
||||
})
|
||||
const formRules = reactive({
|
||||
name: [{ required: true, message: '请输入知识库名称', trigger: 'blur' }],
|
||||
embeddingModelId: [{ required: true, message: '请输入向量模型', trigger: 'blur' }],
|
||||
topK: [{ required: true, message: '请输入检索 topK', trigger: 'blur' }],
|
||||
similarityThreshold: [{ required: true, message: '请输入检索相似度阈值', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择是否启用', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const modelList = ref<ModelVO[]>([]) // 向量模型选项
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 获取向量模型列表
|
||||
modelList.value = await ModelApi.getModelSimpleList(AiModelTypeEnum.EMBEDDING)
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await KnowledgeApi.getKnowledge(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as KnowledgeVO
|
||||
if (formType.value === 'create') {
|
||||
await KnowledgeApi.createKnowledge(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await KnowledgeApi.updateKnowledge(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
embeddingModelId: undefined,
|
||||
topK: undefined,
|
||||
similarityThreshold: undefined,
|
||||
status: CommonStatusEnum.ENABLE // 默认开启
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
221
src/views/ai/knowledge/knowledge/index.vue
Normal file
221
src/views/ai/knowledge/knowledge/index.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<doc-alert title="AI 知识库" url="https://doc.iocoder.cn/ai/knowledge/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="95px"
|
||||
>
|
||||
<el-form-item label="知识库名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入知识库名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择是否启用"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-220px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['ai:knowledge:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="编号" align="center" prop="id" />
|
||||
<el-table-column label="知识库名称" align="center" prop="name" />
|
||||
<el-table-column label="知识库描述" align="center" prop="description" />
|
||||
<el-table-column label="向量化模型" align="center" prop="embeddingModel" />
|
||||
<el-table-column label="是否启用" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" min-width="120px">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleDocument(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:query']"
|
||||
>
|
||||
文档
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleRetrieval(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:query']"
|
||||
>
|
||||
召回测试
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<KnowledgeForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { KnowledgeApi, KnowledgeVO } from '@/api/ai/knowledge/knowledge'
|
||||
import KnowledgeForm from './KnowledgeForm.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
/** AI 知识库列表 */
|
||||
defineOptions({ name: 'Knowledge' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<KnowledgeVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await KnowledgeApi.getKnowledgePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await KnowledgeApi.deleteKnowledge(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 文档按钮操作 */
|
||||
const router = useRouter()
|
||||
const handleDocument = (id: number) => {
|
||||
router.push({
|
||||
name: 'AiKnowledgeDocument',
|
||||
query: { knowledgeId: id }
|
||||
})
|
||||
}
|
||||
|
||||
/** 跳转到文档召回测试页面 */
|
||||
const handleRetrieval = (id: number) => {
|
||||
router.push({
|
||||
name: 'AiKnowledgeRetrieval',
|
||||
query: { id }
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
163
src/views/ai/knowledge/knowledge/retrieval/index.vue
Normal file
163
src/views/ai/knowledge/knowledge/retrieval/index.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="flex gap-20px w-full">
|
||||
<!-- 左侧输入区域 -->
|
||||
<ContentWrap class="flex-1 min-w-300px">
|
||||
<div class="mb-15px">
|
||||
<h3 class="m-0 mb-5px">召回测试</h3>
|
||||
<div class="text-gray-500 text-14px">根据给定的查询文本测试召回效果。</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="relative mb-10px">
|
||||
<el-input
|
||||
v-model="queryParams.content"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="请输入文本"
|
||||
/>
|
||||
<div class="absolute bottom-10px right-10px text-gray-400 text-12px">
|
||||
{{ queryParams.content?.length }} / 200
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-10px">
|
||||
<span class="w-60px text-gray-500">topK:</span>
|
||||
<el-input-number v-model="queryParams.topK" :min="1" :max="20" />
|
||||
</div>
|
||||
<div class="flex items-center mb-15px">
|
||||
<span class="w-60px text-gray-500">相似度:</span>
|
||||
<el-input-number
|
||||
v-model="queryParams.similarityThreshold"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<el-button type="primary" @click="getRetrievalResult" :loading="loading">测试</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 右侧召回结果区域 -->
|
||||
<ContentWrap class="flex-1 min-w-300px">
|
||||
<el-empty v-if="loading" description="正在检索中..." />
|
||||
<div v-else-if="segments.length > 0" class="font-bold mb-15px">
|
||||
{{ segments.length }} 个召回段落
|
||||
</div>
|
||||
<el-empty v-else description="暂无召回结果" />
|
||||
<div>
|
||||
<div
|
||||
v-for="(segment, index) in segments"
|
||||
:key="index"
|
||||
class="mb-20px border border-solid border-gray-200 rounded p-15px"
|
||||
>
|
||||
<div class="flex justify-between text-12px text-gray-500 mb-5px">
|
||||
<span>
|
||||
分段({{ segment.id }}) · {{ segment.contentLength }} 字符数 ·
|
||||
{{ segment.tokens }} Token
|
||||
</span>
|
||||
<span class="px-8px py-4px bg-blue-50 text-blue-500 rounded-full text-12px font-bold">
|
||||
score: {{ segment.score }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50 p-10px rounded mb-10px whitespace-pre-wrap overflow-hidden transition-all duration-100 text-13px"
|
||||
:class="{
|
||||
'line-clamp-2 max-h-50px': !segment.expanded,
|
||||
'max-h-500px': segment.expanded
|
||||
}"
|
||||
>
|
||||
{{ segment.content }}
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center text-gray-500 text-13px">
|
||||
<Icon icon="ep:document" class="mr-5px" />
|
||||
<span>{{ segment.documentName || '未知文档' }}</span>
|
||||
</div>
|
||||
<el-button size="small" @click="toggleExpand(segment)">
|
||||
{{ segment.expanded ? '收起' : '展开' }}
|
||||
<Icon :icon="segment.expanded ? 'ep:arrow-up' : 'ep:arrow-down'" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { KnowledgeSegmentApi } from '@/api/ai/knowledge/segment'
|
||||
import { KnowledgeApi } from '@/api/ai/knowledge/knowledge'
|
||||
/** 文档召回测试 */
|
||||
defineOptions({ name: 'KnowledgeDocumentRetrieval' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const route = useRoute() // 路由
|
||||
const router = useRouter() // 路由
|
||||
|
||||
const loading = ref(false) // 加载状态
|
||||
const segments = ref<any[]>([]) // 召回结果
|
||||
const queryParams = reactive({
|
||||
id: undefined,
|
||||
content: '',
|
||||
topK: 10,
|
||||
similarityThreshold: 0.5
|
||||
})
|
||||
|
||||
/** 调用文档召回测试接口 */
|
||||
const getRetrievalResult = async () => {
|
||||
if (!queryParams.content) {
|
||||
message.warning('请输入查询文本')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
segments.value = []
|
||||
|
||||
try {
|
||||
const data = await KnowledgeSegmentApi.searchKnowledgeSegment({
|
||||
knowledgeId: queryParams.id,
|
||||
content: queryParams.content,
|
||||
topK: queryParams.topK,
|
||||
similarityThreshold: queryParams.similarityThreshold
|
||||
})
|
||||
segments.value = data || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开/收起段落内容 */
|
||||
const toggleExpand = (segment: any) => {
|
||||
segment.expanded = !segment.expanded
|
||||
}
|
||||
|
||||
/** 获取知识库信息 */
|
||||
const getKnowledgeInfo = async (id: number) => {
|
||||
try {
|
||||
const knowledge = await KnowledgeApi.getKnowledge(id)
|
||||
if (knowledge) {
|
||||
queryParams.topK = knowledge.topK || queryParams.topK
|
||||
queryParams.similarityThreshold =
|
||||
knowledge.similarityThreshold || queryParams.similarityThreshold
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
// 如果知识库 ID 不存在,显示错误提示并关闭页面
|
||||
if (!route.query.id) {
|
||||
message.error('知识库 ID 不存在,无法进行召回测试')
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
queryParams.id = route.query.id as any
|
||||
|
||||
// 获取知识库信息并设置默认值
|
||||
getKnowledgeInfo(queryParams.id as any)
|
||||
})
|
||||
</script>
|
||||
101
src/views/ai/knowledge/segment/KnowledgeSegmentForm.vue
Normal file
101
src/views/ai/knowledge/segment/KnowledgeSegmentForm.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="切片内容" prop="content">
|
||||
<el-input
|
||||
v-model="formData.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="请输入切片内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { KnowledgeSegmentApi, KnowledgeSegmentVO } from '@/api/ai/knowledge/segment'
|
||||
|
||||
/** AI 知识库分段表单 */
|
||||
defineOptions({ name: 'KnowledgeSegmentForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
documentId: undefined,
|
||||
content: undefined
|
||||
})
|
||||
const formRules = reactive({
|
||||
content: [{ required: true, message: '切片内容不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number, documentId?: any) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
formData.value.documentId = documentId as any
|
||||
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await KnowledgeSegmentApi.getKnowledgeSegment(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as KnowledgeSegmentVO
|
||||
if (formType.value === 'create') {
|
||||
await KnowledgeSegmentApi.createKnowledgeSegment(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await KnowledgeSegmentApi.updateKnowledgeSegment(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
documentId: undefined,
|
||||
content: undefined
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
242
src/views/ai/knowledge/segment/index.vue
Normal file
242
src/views/ai/knowledge/segment/index.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="文档编号" prop="documentId">
|
||||
<el-input
|
||||
v-model="queryParams.documentId"
|
||||
placeholder="请输入文档编号"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择是否启用"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['ai:knowledge:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="分段编号" align="center" prop="id" />
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<div
|
||||
class="content-expand"
|
||||
style="
|
||||
padding: 10px 20px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #409eff;
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="content-title"
|
||||
style="margin-bottom: 8px; color: #606266; font-size: 14px; font-weight: bold"
|
||||
>
|
||||
完整内容:
|
||||
</div>
|
||||
{{ props.row.content }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="切片内容"
|
||||
align="center"
|
||||
prop="content"
|
||||
min-width="250px"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column label="字符数" align="center" prop="contentLength" />
|
||||
<el-table-column label="token 数量" align="center" prop="tokens" />
|
||||
<el-table-column label="召回次数" align="center" prop="retrievalCount" />
|
||||
<el-table-column label="是否启用" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
:active-value="0"
|
||||
:inactive-value="1"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
:disabled="!checkPermi(['ai:knowledge:update'])"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" min-width="120px">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:knowledge:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<KnowledgeSegmentForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { KnowledgeSegmentApi, KnowledgeSegmentVO } from '@/api/ai/knowledge/segment'
|
||||
import KnowledgeSegmentForm from './KnowledgeSegmentForm.vue'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { checkPermi } from '@/utils/permission'
|
||||
|
||||
/** AI 知识库分段 列表 */
|
||||
defineOptions({ name: 'KnowledgeSegment' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const router = useRouter() // 路由
|
||||
const route = useRoute() // 路由
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<KnowledgeSegmentVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
documentId: undefined,
|
||||
content: undefined,
|
||||
status: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await KnowledgeSegmentApi.getKnowledgeSegmentPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id, queryParams.documentId)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await KnowledgeSegmentApi.deleteKnowledgeSegment(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 修改状态操作 */
|
||||
const handleStatusChange = async (row: KnowledgeSegmentVO) => {
|
||||
try {
|
||||
// 修改状态的二次确认
|
||||
const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '禁用'
|
||||
await message.confirm('确认要"' + text + '"该分段吗?')
|
||||
// 发起修改状态
|
||||
await KnowledgeSegmentApi.updateKnowledgeSegmentStatus({ id: row.id, status: row.status })
|
||||
message.success(t('common.updateSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {
|
||||
// 取消后,进行恢复按钮
|
||||
row.status =
|
||||
row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
// 如果文档 ID 不存在,显示错误提示并关闭页面
|
||||
if (!route.query.documentId) {
|
||||
message.error('文档 ID 不存在,无法查看分段列表')
|
||||
// 关闭当前路由,返回到文档列表页面
|
||||
router.push({ name: 'AiKnowledgeDocument' })
|
||||
return
|
||||
}
|
||||
|
||||
// 从路由参数中获取文档 ID
|
||||
queryParams.documentId = route.query.documentId as any
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
78
src/views/ai/mindmap/index/components/Left.vue
Normal file
78
src/views/ai/mindmap/index/components/Left.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="w-[350px] p-5 flex flex-col bg-[#f5f7f9]">
|
||||
<h3 class="w-full h-full h-7 text-5 text-center leading-[28px] title">思维导图创作中心</h3>
|
||||
<!--下面表单部分-->
|
||||
<div class="flex-grow overflow-y-auto">
|
||||
<div class="mt-[30ppx]">
|
||||
<el-text tag="b">您的需求?</el-text>
|
||||
<el-input
|
||||
v-model="formData.prompt"
|
||||
maxlength="1024"
|
||||
:rows="5"
|
||||
class="w-100% mt-15px"
|
||||
input-style="border-radius: 7px;"
|
||||
placeholder="请输入提示词,让AI帮你完善"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
/>
|
||||
<el-button
|
||||
class="!w-full mt-[15px]"
|
||||
type="primary"
|
||||
:loading="isGenerating"
|
||||
@click="emits('submit', formData)"
|
||||
>
|
||||
智能生成思维导图
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="mt-[30px]">
|
||||
<el-text tag="b">使用已有内容生成?</el-text>
|
||||
<el-input
|
||||
v-model="generatedContent"
|
||||
maxlength="1024"
|
||||
:rows="5"
|
||||
class="w-100% mt-15px"
|
||||
input-style="border-radius: 7px;"
|
||||
placeholder="例如:童话里的小屋应该是什么样子?"
|
||||
show-word-limit
|
||||
type="textarea"
|
||||
/>
|
||||
<el-button
|
||||
class="!w-full mt-[15px]"
|
||||
type="primary"
|
||||
@click="emits('directGenerate', generatedContent)"
|
||||
:disabled="isGenerating"
|
||||
>
|
||||
直接生成
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MindMapContentExample } from '@/views/ai/utils/constants'
|
||||
|
||||
const emits = defineEmits(['submit', 'directGenerate'])
|
||||
defineProps<{
|
||||
isGenerating: boolean
|
||||
}>()
|
||||
// 提交的提示词字段
|
||||
const formData = reactive({
|
||||
prompt: ''
|
||||
})
|
||||
|
||||
const generatedContent = ref(MindMapContentExample) // 已有的内容
|
||||
|
||||
defineExpose({
|
||||
setGeneratedContent(newContent: string) {
|
||||
// 设置已有的内容,在生成结束的时候将结果赋值给该值
|
||||
generatedContent.value = newContent
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
167
src/views/ai/mindmap/index/components/Right.vue
Normal file
167
src/views/ai/mindmap/index/components/Right.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<el-card class="my-card h-full flex-grow">
|
||||
<template #header>
|
||||
<h3 class="m-0 px-7 shrink-0 flex items-center justify-between">
|
||||
<span>思维导图预览</span>
|
||||
<!-- 展示在右上角 -->
|
||||
<el-button v-show="isEnd" size="small" type="primary" @click="downloadImage">
|
||||
<template #icon>
|
||||
<Icon icon="ph:copy-bold" />
|
||||
</template>
|
||||
下载图片
|
||||
</el-button>
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<div ref="contentRef" class="hide-scroll-bar h-full box-border">
|
||||
<!--展示 markdown 的容器,最终生成的是 html 字符串,直接用 v-html 嵌入-->
|
||||
<div v-if="isGenerating" ref="mdContainerRef" class="wh-full overflow-y-auto">
|
||||
<div class="flex flex-col items-center justify-center" v-html="html"></div>
|
||||
</div>
|
||||
|
||||
<div ref="mindMapRef" class="wh-full">
|
||||
<svg ref="svgRef" :style="{ height: `${contentAreaHeight}px` }" class="w-full" />
|
||||
<div ref="toolBarRef" class="absolute bottom-[10px] right-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Markmap } from 'markmap-view'
|
||||
import { Transformer } from 'markmap-lib'
|
||||
import { Toolbar } from 'markmap-toolbar'
|
||||
import markdownit from 'markdown-it'
|
||||
import download from '@/utils/download'
|
||||
|
||||
const md = markdownit()
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const props = defineProps<{
|
||||
generatedContent: string // 生成结果
|
||||
isEnd: boolean // 是否结束
|
||||
isGenerating: boolean // 是否正在生成
|
||||
isStart: boolean // 开始状态,开始时需要清除 html
|
||||
}>()
|
||||
const contentRef = ref<HTMLDivElement>() // 右侧出来 header 以下的区域
|
||||
const mdContainerRef = ref<HTMLDivElement>() // markdown 的容器,用来滚动到底下的
|
||||
const mindMapRef = ref<HTMLDivElement>() // 思维导图的容器
|
||||
const svgRef = ref<SVGElement>() // 思维导图的渲染 svg
|
||||
const toolBarRef = ref<HTMLDivElement>() // 思维导图右下角的工具栏,缩放等
|
||||
const html = ref('') // 生成过程中的文本
|
||||
const contentAreaHeight = ref(0) // 生成区域的高度,出去 header 部分
|
||||
let markMap: Markmap | null = null
|
||||
const transformer = new Transformer()
|
||||
|
||||
onMounted(() => {
|
||||
contentAreaHeight.value = contentRef.value?.clientHeight || 0 // 获取区域高度
|
||||
/** 初始化思维导图 **/
|
||||
try {
|
||||
markMap = Markmap.create(svgRef.value!)
|
||||
const { el } = Toolbar.create(markMap)
|
||||
toolBarRef.value?.append(el)
|
||||
nextTick(update)
|
||||
} catch (e) {
|
||||
message.error('思维导图初始化失败')
|
||||
}
|
||||
})
|
||||
|
||||
watch(props, ({ generatedContent, isGenerating, isEnd, isStart }) => {
|
||||
// 开始生成的时候清空一下 markdown 的内容
|
||||
if (isStart) {
|
||||
html.value = ''
|
||||
}
|
||||
// 生成内容的时候使用 markdown 来渲染
|
||||
if (isGenerating) {
|
||||
html.value = md.render(generatedContent)
|
||||
}
|
||||
// 生成结束时更新思维导图
|
||||
if (isEnd) {
|
||||
update()
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新思维导图的展示 */
|
||||
const update = () => {
|
||||
try {
|
||||
const { root } = transformer.transform(processContent(props.generatedContent))
|
||||
markMap?.setData(root)
|
||||
markMap?.fit()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理内容 */
|
||||
const processContent = (text: string) => {
|
||||
const arr: string[] = []
|
||||
const lines = text.split('\n')
|
||||
for (let line of lines) {
|
||||
if (line.indexOf('```') !== -1) {
|
||||
continue
|
||||
}
|
||||
line = line.replace(/([*_~`>])|(\d+\.)\s/g, '')
|
||||
arr.push(line)
|
||||
}
|
||||
return arr.join('\n')
|
||||
}
|
||||
|
||||
/** 下载图片:download SVG to png file */
|
||||
const downloadImage = () => {
|
||||
const svgElement = mindMapRef.value
|
||||
// 将 SVG 渲染到图片对象
|
||||
const serializer = new XMLSerializer()
|
||||
const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgRef.value!)}`
|
||||
const base64Url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`
|
||||
download.image({
|
||||
url: base64Url,
|
||||
canvasWidth: svgElement?.offsetWidth,
|
||||
canvasHeight: svgElement?.offsetHeight,
|
||||
drawWithImageSize: false
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
scrollBottom() {
|
||||
mdContainerRef.value?.scrollTo(0, mdContainerRef.value?.scrollHeight)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.hide-scroll-bar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.my-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
box-sizing: border-box;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
@extend .hide-scroll-bar;
|
||||
}
|
||||
}
|
||||
|
||||
// markmap的tool样式覆盖
|
||||
:deep(.markmap) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.mm-toolbar-brand) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.mm-toolbar) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
</style>
|
||||
94
src/views/ai/mindmap/index/index.vue
Normal file
94
src/views/ai/mindmap/index/index.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 right-0 bottom-0 flex">
|
||||
<!--表单区域-->
|
||||
<Left
|
||||
ref="leftRef"
|
||||
:is-generating="isGenerating"
|
||||
@submit="submit"
|
||||
@direct-generate="directGenerate"
|
||||
/>
|
||||
<!--右边生成思维导图区域-->
|
||||
<Right
|
||||
ref="rightRef"
|
||||
:generatedContent="generatedContent"
|
||||
:isEnd="isEnd"
|
||||
:isGenerating="isGenerating"
|
||||
:isStart="isStart"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Left from './components/Left.vue'
|
||||
import Right from './components/Right.vue'
|
||||
import { AiMindMapApi, AiMindMapGenerateReqVO } from '@/api/ai/mindmap'
|
||||
import { MindMapContentExample } from '@/views/ai/utils/constants'
|
||||
|
||||
defineOptions({
|
||||
name: 'AiMindMap'
|
||||
})
|
||||
const ctrl = ref<AbortController>() // 请求控制
|
||||
const isGenerating = ref(false) // 是否正在生成思维导图
|
||||
const isStart = ref(false) // 开始生成,用来清空思维导图
|
||||
const isEnd = ref(true) // 用来判断结束的时候渲染思维导图
|
||||
const message = useMessage() // 消息提示
|
||||
|
||||
const generatedContent = ref('') // 生成思维导图结果
|
||||
|
||||
const leftRef = ref<InstanceType<typeof Left>>() // 左边组件
|
||||
const rightRef = ref<InstanceType<typeof Right>>() // 右边组件
|
||||
|
||||
/** 使用已有内容直接生成 **/
|
||||
const directGenerate = (existPrompt: string) => {
|
||||
isEnd.value = false // 先设置为 false 再设置为 true,让子组建的 watch 能够监听到
|
||||
generatedContent.value = existPrompt
|
||||
isEnd.value = true
|
||||
}
|
||||
|
||||
/** 停止 stream 生成 */
|
||||
const stopStream = () => {
|
||||
isGenerating.value = false
|
||||
isStart.value = false
|
||||
ctrl.value?.abort()
|
||||
}
|
||||
|
||||
/** 提交生成 */
|
||||
const submit = (data: AiMindMapGenerateReqVO) => {
|
||||
isGenerating.value = true
|
||||
isStart.value = true
|
||||
isEnd.value = false
|
||||
ctrl.value = new AbortController() // 请求控制赋值
|
||||
generatedContent.value = '' // 清空生成数据
|
||||
AiMindMapApi.generateMindMap({
|
||||
data,
|
||||
onMessage: async (res) => {
|
||||
const { code, data, msg } = JSON.parse(res.data)
|
||||
if (code !== 0) {
|
||||
message.alert(`生成思维导图异常! ${msg}`)
|
||||
stopStream()
|
||||
return
|
||||
}
|
||||
generatedContent.value = generatedContent.value + data
|
||||
await nextTick()
|
||||
rightRef.value?.scrollBottom()
|
||||
},
|
||||
onClose() {
|
||||
isEnd.value = true
|
||||
leftRef.value?.setGeneratedContent(generatedContent.value)
|
||||
stopStream()
|
||||
},
|
||||
onError(err) {
|
||||
console.error('生成思维导图失败', err)
|
||||
stopStream()
|
||||
// 需要抛出异常,禁止重试
|
||||
throw error
|
||||
},
|
||||
ctrl: ctrl.value
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
generatedContent.value = MindMapContentExample
|
||||
})
|
||||
</script>
|
||||
191
src/views/ai/mindmap/manager/index.vue
Normal file
191
src/views/ai/mindmap/manager/index.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<doc-alert title="AI 思维导图" url="https://doc.iocoder.cn/ai/mindmap/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-select
|
||||
v-model="queryParams.userId"
|
||||
clearable
|
||||
placeholder="请输入用户编号"
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in userList"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示词" prop="prompt">
|
||||
<el-input
|
||||
v-model="queryParams.prompt"
|
||||
placeholder="请输入提示词"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
|
||||
<el-table-column label="用户" align="center" prop="userId" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提示词" align="center" prop="prompt" width="180" />
|
||||
<el-table-column label="思维导图" align="center" prop="generatedContent" min-width="300" />
|
||||
<el-table-column label="模型" align="center" prop="model" width="180" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="错误信息" align="center" prop="errorMessage" />
|
||||
<el-table-column label="操作" align="center" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="openPreview(scope.row)"> 预览 </el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:mind-map:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 思维导图的预览 -->
|
||||
<el-drawer v-model="previewVisible" :with-header="false" size="800px">
|
||||
<Right
|
||||
v-if="previewVisible2"
|
||||
:generatedContent="previewContent"
|
||||
:isEnd="true"
|
||||
:isGenerating="false"
|
||||
:isStart="false"
|
||||
/>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { AiMindMapApi, MindMapVO } from '@/api/ai/mindmap'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import Right from '@/views/ai/mindmap/index/components/Right.vue'
|
||||
|
||||
/** AI 思维导图 列表 */
|
||||
defineOptions({ name: 'AiMindMapManager' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<MindMapVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
prompt: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const userList = ref<UserApi.UserVO[]>([]) // 用户列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await AiMindMapApi.getMindMapPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await AiMindMapApi.deleteMindMap(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 预览操作按钮 */
|
||||
const previewVisible = ref(false) // drawer 的显示隐藏
|
||||
const previewVisible2 = ref(false) // right 的显示隐藏
|
||||
const previewContent = ref('')
|
||||
const openPreview = async (row: MindMapVO) => {
|
||||
previewVisible2.value = false
|
||||
previewVisible.value = true
|
||||
// 在 drawer 渲染完后,再渲染 right 预览,不然会报错,需要保证 width 宽度先出来
|
||||
await nextTick()
|
||||
previewVisible2.value = true
|
||||
previewContent.value = row.generatedContent
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
getList()
|
||||
// 获得用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
})
|
||||
</script>
|
||||
132
src/views/ai/model/apiKey/ApiKeyForm.vue
Normal file
132
src/views/ai/model/apiKey/ApiKeyForm.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="120px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="所属平台" prop="platform">
|
||||
<el-select v-model="formData.platform" placeholder="请输入平台" clearable>
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密钥" prop="apiKey">
|
||||
<el-input v-model="formData.apiKey" placeholder="请输入密钥" />
|
||||
</el-form-item>
|
||||
<el-form-item label="自定义 API URL" prop="url">
|
||||
<el-input v-model="formData.url" placeholder="请输入自定义 API URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
|
||||
import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
|
||||
/** AI API 密钥 表单 */
|
||||
defineOptions({ name: 'ApiKeyForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
apiKey: undefined,
|
||||
platform: undefined,
|
||||
url: undefined,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
const formRules = reactive({
|
||||
name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
|
||||
apiKey: [{ required: true, message: '密钥不能为空', trigger: 'blur' }],
|
||||
platform: [{ required: true, message: '平台不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ApiKeyApi.getApiKey(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ApiKeyVO
|
||||
if (formType.value === 'create') {
|
||||
await ApiKeyApi.createApiKey(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await ApiKeyApi.updateApiKey(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
apiKey: undefined,
|
||||
platform: undefined,
|
||||
url: undefined,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
182
src/views/ai/model/apiKey/index.vue
Normal file
182
src/views/ai/model/apiKey/index.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<doc-alert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台" prop="platform">
|
||||
<el-select
|
||||
v-model="queryParams.platform"
|
||||
placeholder="请输入平台"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['ai:api-key:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="所属平台" align="center" prop="platform">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称" align="center" prop="name" />
|
||||
<el-table-column label="密钥" align="center" prop="apiKey" />
|
||||
<el-table-column label="自定义 API URL" align="center" prop="url" />
|
||||
<el-table-column label="状态" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:api-key:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:api-key:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<ApiKeyForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
|
||||
import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
|
||||
import ApiKeyForm from './ApiKeyForm.vue'
|
||||
|
||||
/** AI API 密钥 列表 */
|
||||
defineOptions({ name: 'AiApiKey' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ApiKeyVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
platform: undefined,
|
||||
status: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ApiKeyApi.getApiKeyPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ApiKeyApi.deleteApiKey(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
211
src/views/ai/model/chatRole/ChatRoleForm.vue
Normal file
211
src/views/ai/model/chatRole/ChatRoleForm.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入角色名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色头像" prop="avatar">
|
||||
<UploadImg v-model="formData.avatar" height="60px" width="60px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="绑定模型" prop="modelId" v-if="!isUser">
|
||||
<el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
|
||||
<el-option
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:label="model.name"
|
||||
:value="model.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色类别" prop="category" v-if="!isUser">
|
||||
<el-input v-model="formData.category" placeholder="请输入角色类别" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色描述" prop="description">
|
||||
<el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色设定" prop="systemMessage">
|
||||
<el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
|
||||
</el-form-item>
|
||||
<el-form-item label="引用知识库" prop="knowledgeIds">
|
||||
<el-select v-model="formData.knowledgeIds" placeholder="请选择知识库" clearable multiple>
|
||||
<el-option
|
||||
v-for="item in knowledgeList"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="引用工具" prop="toolIds">
|
||||
<el-select v-model="formData.toolIds" placeholder="请选择工具" clearable multiple>
|
||||
<el-option v-for="item in toolList" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
|
||||
<el-radio-group v-model="formData.publicStatus">
|
||||
<el-radio
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色排序" prop="sort" v-if="!isUser">
|
||||
<el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开启状态" prop="status" v-if="!isUser">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
import { FormRules } from 'element-plus'
|
||||
import { AiModelTypeEnum } from '@/views/ai/utils/constants'
|
||||
import { KnowledgeApi, KnowledgeVO } from '@/api/ai/knowledge/knowledge'
|
||||
import { ToolApi, ToolVO } from '@/api/ai/model/tool'
|
||||
|
||||
/** AI 聊天角色 表单 */
|
||||
defineOptions({ name: 'ChatRoleForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
modelId: undefined,
|
||||
name: undefined,
|
||||
avatar: undefined,
|
||||
category: undefined,
|
||||
sort: undefined,
|
||||
description: undefined,
|
||||
systemMessage: undefined,
|
||||
publicStatus: true,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
knowledgeIds: [] as number[],
|
||||
toolIds: [] as number[]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const models = ref([] as ModelVO[]) // 聊天模型列表
|
||||
const knowledgeList = ref([] as KnowledgeVO[]) // 知识库列表
|
||||
const toolList = ref([] as ToolVO[]) // 工具列表
|
||||
|
||||
/** 是否【我】自己创建,私有角色 */
|
||||
const isUser = computed(() => {
|
||||
return formType.value === 'my-create' || formType.value === 'my-update'
|
||||
})
|
||||
|
||||
const formRules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
|
||||
avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
|
||||
sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
|
||||
description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
|
||||
systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
|
||||
publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
/** 打开弹窗 */
|
||||
// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
|
||||
const open = async (type: string, id?: number, title?: string) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = title || t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ChatRoleApi.getChatRole(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
// 获得下拉数据
|
||||
models.value = await ModelApi.getModelSimpleList(AiModelTypeEnum.CHAT)
|
||||
// 获取知识库列表
|
||||
knowledgeList.value = await KnowledgeApi.getSimpleKnowledgeList()
|
||||
// 获取工具列表
|
||||
toolList.value = await ToolApi.getToolSimpleList()
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ChatRoleVO
|
||||
// tip: my-create、my-update 是 chat 角色仓库调用
|
||||
// tip: create、else 是后台管理调用
|
||||
if (formType.value === 'my-create') {
|
||||
await ChatRoleApi.createMy(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else if (formType.value === 'my-update') {
|
||||
await ChatRoleApi.updateMy(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
} else if (formType.value === 'create') {
|
||||
await ChatRoleApi.createChatRole(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await ChatRoleApi.updateChatRole(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
modelId: undefined,
|
||||
name: undefined,
|
||||
avatar: undefined,
|
||||
category: undefined,
|
||||
sort: undefined,
|
||||
description: undefined,
|
||||
systemMessage: undefined,
|
||||
publicStatus: true,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
knowledgeIds: [],
|
||||
toolIds: []
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
201
src/views/ai/model/chatRole/index.vue
Normal file
201
src/views/ai/model/chatRole/index.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<doc-alert title="AI 对话聊天" url="https://doc.iocoder.cn/ai/chat/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="角色名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入角色名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色类别" prop="category">
|
||||
<el-input
|
||||
v-model="queryParams.category"
|
||||
placeholder="请输入角色类别"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否公开" prop="publicStatus">
|
||||
<el-select
|
||||
v-model="queryParams.publicStatus"
|
||||
placeholder="请选择是否公开"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['ai:chat-role:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="角色名称" align="center" prop="name" />
|
||||
<el-table-column label="绑定模型" align="center" prop="modelName" />
|
||||
<el-table-column label="角色头像" align="center" prop="avatar">
|
||||
<template #default="scope">
|
||||
<el-image :src="scope?.row.avatar" class="w-32px h-32px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色类别" align="center" prop="category" />
|
||||
<el-table-column label="角色描述" align="center" prop="description" />
|
||||
<el-table-column label="角色设定" align="center" prop="systemMessage" />
|
||||
<el-table-column label="知识库" align="center" prop="knowledgeIds">
|
||||
<template #default="scope">
|
||||
<span v-if="!scope.row.knowledgeIds || scope.row.knowledgeIds.length === 0">-</span>
|
||||
<span v-else>引用 {{ scope.row.knowledgeIds.length }} 个</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工具" align="center" prop="toolIds">
|
||||
<template #default="scope">
|
||||
<span v-if="!scope.row.toolIds || scope.row.toolIds.length === 0">-</span>
|
||||
<span v-else>引用 {{ scope.row.toolIds.length }} 个</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否公开" align="center" prop="publicStatus">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.publicStatus" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色排序" align="center" prop="sort" />
|
||||
<el-table-column label="操作" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:chat-role:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:chat-role:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<ChatRoleForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
|
||||
import ChatRoleForm from './ChatRoleForm.vue'
|
||||
|
||||
/** AI 聊天角色 列表 */
|
||||
defineOptions({ name: 'AiChatRole' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ChatRoleVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
category: undefined,
|
||||
publicStatus: true
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ChatRoleApi.getChatRolePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ChatRoleApi.deleteChatRole(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
223
src/views/ai/model/model/ModelForm.vue
Normal file
223
src/views/ai/model/model/ModelForm.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="130px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="所属平台" prop="platform">
|
||||
<el-select v-model="formData.platform" placeholder="请输入平台" clearable>
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型类型" prop="type">
|
||||
<el-select
|
||||
v-model="formData.type"
|
||||
placeholder="请输入模型类型"
|
||||
clearable
|
||||
:disabled="formData.id"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.AI_MODEL_TYPE)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 秘钥" prop="keyId">
|
||||
<el-select v-model="formData.keyId" placeholder="请选择 API 秘钥" clearable>
|
||||
<el-option
|
||||
v-for="apiKey in apiKeyList"
|
||||
:key="apiKey.id"
|
||||
:label="apiKey.name"
|
||||
:value="apiKey.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名字" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入模型名字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型标识" prop="model">
|
||||
<el-input v-model="formData.model" placeholder="请输入模型标识" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" placeholder="请输入模型排序" class="!w-1/1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开启状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="温度参数"
|
||||
prop="temperature"
|
||||
v-if="formData.type === AiModelTypeEnum.CHAT"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="formData.temperature"
|
||||
placeholder="请输入温度参数"
|
||||
:min="0"
|
||||
:max="2"
|
||||
:precision="2"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="回复数 Token 数"
|
||||
prop="maxTokens"
|
||||
v-if="formData.type === AiModelTypeEnum.CHAT"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="formData.maxTokens"
|
||||
placeholder="请输入回复数 Token 数"
|
||||
:min="0"
|
||||
:max="8192"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
label="上下文数量"
|
||||
prop="maxContexts"
|
||||
v-if="formData.type === AiModelTypeEnum.CHAT"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="formData.maxContexts"
|
||||
placeholder="请输入上下文数量"
|
||||
:min="0"
|
||||
:max="20"
|
||||
class="!w-1/1"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
|
||||
import { AiModelTypeEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
/** API 模型的表单 */
|
||||
defineOptions({ name: 'ModelForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
keyId: undefined,
|
||||
name: undefined,
|
||||
model: undefined,
|
||||
platform: undefined,
|
||||
type: undefined,
|
||||
sort: undefined,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
temperature: undefined,
|
||||
maxTokens: undefined,
|
||||
maxContexts: undefined
|
||||
})
|
||||
const formRules = reactive({
|
||||
keyId: [{ required: true, message: 'API 秘钥不能为空', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '模型名字不能为空', trigger: 'blur' }],
|
||||
model: [{ required: true, message: '模型标识不能为空', trigger: 'blur' }],
|
||||
platform: [{ required: true, message: '所属平台不能为空', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '模型类型不能为空', trigger: 'blur' }],
|
||||
sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
|
||||
temperature: [{ required: true, message: '温度参数不能为空', trigger: 'blur' }],
|
||||
maxTokens: [{ required: true, message: '回复数 Token 数不能为空', trigger: 'blur' }],
|
||||
maxContexts: [{ required: true, message: '上下文数量不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ModelApi.getModel(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
// 获得下拉数据
|
||||
apiKeyList.value = await ApiKeyApi.getApiKeySimpleList()
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ModelVO
|
||||
if (data.type !== AiModelTypeEnum.CHAT) {
|
||||
delete data.temperature
|
||||
delete data.maxTokens
|
||||
delete data.maxContexts
|
||||
}
|
||||
if (formType.value === 'create') {
|
||||
await ModelApi.createModel(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await ModelApi.updateModel(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
keyId: undefined,
|
||||
name: undefined,
|
||||
model: undefined,
|
||||
platform: undefined,
|
||||
type: undefined,
|
||||
sort: undefined,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
temperature: undefined,
|
||||
maxTokens: undefined,
|
||||
maxContexts: undefined
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
192
src/views/ai/model/model/index.vue
Normal file
192
src/views/ai/model/model/index.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<doc-alert title="AI 手册" url="https://doc.iocoder.cn/ai/build/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="模型名字" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入模型名字"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型标识" prop="model">
|
||||
<el-input
|
||||
v-model="queryParams.model"
|
||||
placeholder="请输入模型标识"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型平台" prop="platform">
|
||||
<el-input
|
||||
v-model="queryParams.platform"
|
||||
placeholder="请输入模型平台"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['ai:model:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="所属平台" align="center" prop="platform" min-width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型类型" align="center" prop="platform" min-width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_MODEL_TYPE" :value="scope.row.type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型名字" align="center" prop="name" min-width="180" />
|
||||
<el-table-column label="模型标识" align="center" prop="model" min-width="180" />
|
||||
<el-table-column label="API 秘钥" align="center" prop="keyId" min-width="140">
|
||||
<template #default="scope">
|
||||
<span>{{ apiKeyList.find((item) => item.id === scope.row.keyId)?.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" align="center" prop="sort" min-width="80" />
|
||||
<el-table-column label="状态" align="center" prop="status" min-width="80">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="温度参数" align="center" prop="temperature" min-width="80" />
|
||||
<el-table-column label="回复数 Token 数" align="center" prop="maxTokens" min-width="140" />
|
||||
<el-table-column label="上下文数量" align="center" prop="maxContexts" min-width="100" />
|
||||
<el-table-column label="操作" align="center" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:model:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:model:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<ModelForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ModelApi, ModelVO } from '@/api/ai/model/model'
|
||||
import ModelForm from './ModelForm.vue'
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
|
||||
|
||||
/** API 模型列表 */
|
||||
defineOptions({ name: 'AiModel' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ModelVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
model: undefined,
|
||||
platform: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ModelApi.getModelPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ModelApi.deleteModel(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
await getList()
|
||||
// 获得下拉数据
|
||||
apiKeyList.value = await ApiKeyApi.getApiKeySimpleList()
|
||||
})
|
||||
</script>
|
||||
@@ -1,35 +1,43 @@
|
||||
<template>
|
||||
<Dialog v-model="dialogVisible" :title="dialogTitle">
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
v-loading="formLoading"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="应用名" prop="applicationName">
|
||||
<el-input v-model="formData.applicationName" clearable placeholder="请输入应用名" />
|
||||
<el-form-item label="工具名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入工具名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="错误码编码" prop="code">
|
||||
<el-input v-model="formData.code" clearable placeholder="请输入错误码编码" />
|
||||
<el-form-item label="工具描述" prop="description">
|
||||
<el-input type="textarea" v-model="formData.description" placeholder="请输入工具描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="错误码提示" prop="message">
|
||||
<el-input v-model="formData.message" clearable placeholder="请输入错误码提示" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="memo">
|
||||
<el-input v-model="formData.memo" clearable placeholder="请输入备注" />
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import * as ErrorCodeApi from '@/api/system/errorCode'
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { ToolApi, ToolVO } from '@/api/ai/model/tool'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
|
||||
defineOptions({ name: 'SystemErrorCodeForm' })
|
||||
/** AI 工具表单 */
|
||||
defineOptions({ name: 'ToolForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
@@ -38,19 +46,14 @@ const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
// 表单参数
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
code: undefined,
|
||||
applicationName: '',
|
||||
message: '',
|
||||
memo: ''
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
// 表单校验
|
||||
const formRules = reactive({
|
||||
applicationName: [{ required: true, message: '应用名不能为空', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '错误码编码不能为空', trigger: 'blur' }],
|
||||
message: [{ required: true, message: '错误码提示不能为空', trigger: 'blur' }]
|
||||
name: [{ required: true, message: '工具名称不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
|
||||
@@ -64,7 +67,7 @@ const open = async (type: string, id?: number) => {
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ErrorCodeApi.getErrorCode(id)
|
||||
formData.value = await ToolApi.getTool(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
@@ -76,18 +79,16 @@ defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
if (!formRef) return
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as ErrorCodeApi.ErrorCodeVO
|
||||
const data = formData.value as unknown as ToolVO
|
||||
if (formType.value === 'create') {
|
||||
await ErrorCodeApi.createErrorCode(data)
|
||||
await ToolApi.createTool(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await ErrorCodeApi.updateErrorCode(data)
|
||||
await ToolApi.updateTool(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
@@ -98,14 +99,13 @@ const submitForm = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单重置 */
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
applicationName: '',
|
||||
code: undefined,
|
||||
message: '',
|
||||
memo: ''
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
178
src/views/ai/model/tool/index.vue
Normal file
178
src/views/ai/model/tool/index.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<doc-alert title="AI 工具调用(function calling)" url="https://doc.iocoder.cn/ai/tool/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="工具名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入工具名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-220px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['ai:tool:create']">
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="工具编号" align="center" prop="id" />
|
||||
<el-table-column label="工具名称" align="center" prop="name" />
|
||||
<el-table-column label="工具描述" align="center" prop="description" />
|
||||
<el-table-column label="状态" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" min-width="120px">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:tool:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:tool:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<ToolForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { ToolApi, ToolVO } from '@/api/ai/model/tool'
|
||||
import ToolForm from './ToolForm.vue'
|
||||
|
||||
/** AI 工具 列表 */
|
||||
defineOptions({ name: 'AiTool' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<ToolVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
status: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const exportLoading = ref(false) // 导出的加载中
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ToolApi.getToolPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ToolApi.deleteTool(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
26
src/views/ai/music/index/index.vue
Normal file
26
src/views/ai/music/index/index.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="flex h-full items-stretch">
|
||||
<!-- 模式 -->
|
||||
<Mode class="flex-none" @generate-music="generateMusic"/>
|
||||
<!-- 音频列表 -->
|
||||
<List ref="listRef" class="flex-auto"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Mode from './mode/index.vue'
|
||||
import List from './list/index.vue'
|
||||
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
const listRef = ref<Nullable<{generateMusic: (...args) => void}>>(null)
|
||||
|
||||
/*
|
||||
*@Description: 拿到左侧配置信息调用右侧音乐生成的方法
|
||||
*@MethodAuthor: xiaohong
|
||||
*@Date: 2024-07-19 11:13:38
|
||||
*/
|
||||
function generateMusic (args: {formData: Recordable}) {
|
||||
unref(listRef)?.generateMusic(args.formData)
|
||||
}
|
||||
</script>
|
||||
70
src/views/ai/music/index/list/audioBar/index.vue
Normal file
70
src/views/ai/music/index/list/audioBar/index.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between px-2 h-72px bg-[var(--el-bg-color-overlay)] b-solid b-1 b-[var(--el-border-color)] b-l-none">
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex gap-[10px]">
|
||||
<el-image src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png" class="w-[45px]"/>
|
||||
<div>
|
||||
<div>{{currentSong.name}}</div>
|
||||
<div class="text-[12px] text-gray-400">{{currentSong.singer}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频controls -->
|
||||
<div class="flex gap-[12px] items-center">
|
||||
<Icon icon="majesticons:back-circle" :size="20" class="text-gray-300 cursor-pointer"/>
|
||||
<Icon :icon="audioProps.paused ? 'mdi:arrow-right-drop-circle' : 'solar:pause-circle-bold'" :size="30" class=" cursor-pointer" @click="toggleStatus('paused')"/>
|
||||
<Icon icon="majesticons:next-circle" :size="20" class="text-gray-300 cursor-pointer"/>
|
||||
<div class="flex gap-[16px] items-center">
|
||||
<span>{{audioProps.currentTime}}</span>
|
||||
<el-slider v-model="audioProps.duration" color="#409eff" class="w-[160px!important] "/>
|
||||
<span>{{ audioProps.duration }}</span>
|
||||
</div>
|
||||
<!-- 音频 -->
|
||||
<audio v-bind="audioProps" ref="audioRef" controls v-show="!audioProps" @timeupdate="audioTimeUpdate">
|
||||
<source :src="audioUrl"/>
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- 音量控制器 -->
|
||||
<div class="flex gap-[16px] items-center">
|
||||
<Icon :icon="audioProps.muted ? 'tabler:volume-off' : 'tabler:volume'" :size="20" class="cursor-pointer" @click="toggleStatus('muted')"/>
|
||||
<el-slider v-model="audioProps.volume" color="#409eff" class="w-[160px!important] "/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatPast } from '@/utils/formatTime'
|
||||
import audioUrl from '@/assets/audio/response.mp3'
|
||||
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
const currentSong = inject('currentSong', {})
|
||||
|
||||
const audioRef = ref<Nullable<HTMLElement>>(null)
|
||||
// 音频相关属性https://www.runoob.com/tags/ref-av-dom.html
|
||||
const audioProps = reactive({
|
||||
autoplay: true,
|
||||
paused: false,
|
||||
currentTime: '00:00',
|
||||
duration: '00:00',
|
||||
muted: false,
|
||||
volume: 50,
|
||||
})
|
||||
|
||||
function toggleStatus (type: string) {
|
||||
audioProps[type] = !audioProps[type]
|
||||
if (type === 'paused' && audioRef.value) {
|
||||
if (audioProps[type]) {
|
||||
audioRef.value.pause()
|
||||
} else {
|
||||
audioRef.value.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新播放位置
|
||||
function audioTimeUpdate (args) {
|
||||
audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss')
|
||||
}
|
||||
</script>
|
||||
108
src/views/ai/music/index/list/index.vue
Normal file
108
src/views/ai/music/index/list/index.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex-auto flex overflow-hidden">
|
||||
<el-tabs v-model="currentType" class="flex-auto px-[var(--app-content-padding)]">
|
||||
<!-- 我的创作 -->
|
||||
<el-tab-pane v-loading="loading" label="我的创作" name="mine">
|
||||
<el-row v-if="mySongList.length" :gutter="12">
|
||||
<el-col v-for="song in mySongList" :key="song.id" :span="24">
|
||||
<songCard :songInfo="song" @play="setCurrentSong(song)"/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty v-else description="暂无音乐"/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 试听广场 -->
|
||||
<el-tab-pane v-loading="loading" label="试听广场" name="square">
|
||||
<el-row v-if="squareSongList.length" v-loading="loading" :gutter="12">
|
||||
<el-col v-for="song in squareSongList" :key="song.id" :span="24">
|
||||
<songCard :songInfo="song" @play="setCurrentSong(song)"/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty v-else description="暂无音乐"/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<!-- songInfo -->
|
||||
<songInfo class="flex-none"/>
|
||||
</div>
|
||||
<audioBar class="flex-none"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import songCard from './songCard/index.vue'
|
||||
import songInfo from './songInfo/index.vue'
|
||||
import audioBar from './audioBar/index.vue'
|
||||
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
|
||||
const currentType = ref('mine')
|
||||
// loading 状态
|
||||
const loading = ref(false)
|
||||
// 当前音乐
|
||||
const currentSong = ref({})
|
||||
|
||||
const mySongList = ref<Recordable[]>([])
|
||||
const squareSongList = ref<Recordable[]>([])
|
||||
|
||||
provide('currentSong', currentSong)
|
||||
|
||||
/*
|
||||
*@Description: 调接口生成音乐列表
|
||||
*@MethodAuthor: xiaohong
|
||||
*@Date: 2024-06-27 17:06:44
|
||||
*/
|
||||
function generateMusic (formData: Recordable) {
|
||||
console.log(formData);
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
mySongList.value = Array.from({ length: 20 }, (_, index) => {
|
||||
return {
|
||||
id: index,
|
||||
audioUrl: '',
|
||||
videoUrl: '',
|
||||
title: '我走后' + index,
|
||||
imageUrl: 'https://www.carsmp3.com/data/attachment/forum/201909/19/091020q5kgre20fidreqyt.jpg',
|
||||
desc: 'Metal, symphony, film soundtrack, grand, majesticMetal, dtrack, grand, majestic',
|
||||
date: '2024年04月30日 14:02:57',
|
||||
lyric: `<div class="_words_17xen_66"><div>大江东去,浪淘尽,千古风流人物。
|
||||
</div><div>故垒西边,人道是,三国周郎赤壁。
|
||||
</div><div>乱石穿空,惊涛拍岸,卷起千堆雪。
|
||||
</div><div>江山如画,一时多少豪杰。
|
||||
</div><div>
|
||||
</div><div>遥想公瑾当年,小乔初嫁了,雄姿英发。
|
||||
</div><div>羽扇纶巾,谈笑间,樯橹灰飞烟灭。
|
||||
</div><div>故国神游,多情应笑我,早生华发。
|
||||
</div><div>人生如梦,一尊还酹江月。</div></div>`
|
||||
}
|
||||
})
|
||||
loading.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/*
|
||||
*@Description: 设置当前播放的音乐
|
||||
*@MethodAuthor: xiaohong
|
||||
*@Date: 2024-07-19 11:22:33
|
||||
*/
|
||||
function setCurrentSong (music: Recordable) {
|
||||
currentSong.value = music
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
generateMusic
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.el-tabs__content {
|
||||
padding: 0 7px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
src/views/ai/music/index/list/songCard/index.vue
Normal file
36
src/views/ai/music/index/list/songCard/index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="flex bg-[var(--el-bg-color-overlay)] p-12px mb-12px rounded-1">
|
||||
<div class="relative" @click="playSong">
|
||||
<el-image :src="songInfo.imageUrl" class="flex-none w-80px"/>
|
||||
<div class="bg-black bg-op-40 absolute top-0 left-0 w-full h-full flex items-center justify-center cursor-pointer">
|
||||
<Icon :icon="currentSong.id === songInfo.id ? 'solar:pause-circle-bold':'mdi:arrow-right-drop-circle'" :size="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-8px">
|
||||
<div>{{ songInfo.title }}</div>
|
||||
<div class="mt-8px text-12px text-[var(--el-text-color-secondary)] line-clamp-2">
|
||||
{{ songInfo.desc }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
defineProps({
|
||||
songInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits(['play'])
|
||||
|
||||
const currentSong = inject('currentSong', {})
|
||||
|
||||
function playSong () {
|
||||
emits('play')
|
||||
}
|
||||
</script>
|
||||
22
src/views/ai/music/index/list/songInfo/index.vue
Normal file
22
src/views/ai/music/index/list/songInfo/index.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<ContentWrap class="w-300px mb-[0!important] line-height-24px">
|
||||
<el-image :src="currentSong.imageUrl"/>
|
||||
<div class="">{{ currentSong.title }}</div>
|
||||
<div class="text-[var(--el-text-color-secondary)] text-12px line-clamp-1">
|
||||
{{ currentSong.desc }}
|
||||
</div>
|
||||
<div class="text-[var(--el-text-color-secondary)] text-12px">
|
||||
{{ currentSong.date }}
|
||||
</div>
|
||||
<el-button size="small" round class="my-6px">信息复用</el-button>
|
||||
<div class="text-[var(--el-text-color-secondary)] text-12px" v-html="currentSong.lyric"></div>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
const currentSong = inject('currentSong', {})
|
||||
|
||||
</script>
|
||||
55
src/views/ai/music/index/mode/desc.vue
Normal file
55
src/views/ai/music/index/mode/desc.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<Title title="音乐/歌词说明" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲">
|
||||
<el-input
|
||||
v-model="formData.desc"
|
||||
:autosize="{ minRows: 6, maxRows: 6}"
|
||||
resize="none"
|
||||
type="textarea"
|
||||
maxlength="1200"
|
||||
show-word-limit
|
||||
placeholder="一首关于糟糕分手的欢快歌曲"
|
||||
/>
|
||||
</Title>
|
||||
|
||||
<Title title="纯音乐" desc="创建一首没有歌词的歌曲">
|
||||
<template #extra>
|
||||
<el-switch v-model="formData.pure" size="small"/>
|
||||
</template>
|
||||
</Title>
|
||||
|
||||
<Title title="版本" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲">
|
||||
<el-select v-model="formData.version" placeholder="请选择">
|
||||
<el-option
|
||||
v-for="item in [{
|
||||
value: '3',
|
||||
label: 'V3'
|
||||
}, {
|
||||
value: '2',
|
||||
label: 'V2'
|
||||
}]"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</Title>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Title from '../title/index.vue'
|
||||
|
||||
defineOptions({ name: 'Desc' })
|
||||
|
||||
const formData = reactive({
|
||||
desc: '',
|
||||
pure: false,
|
||||
version: '3'
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
formData
|
||||
})
|
||||
|
||||
</script>
|
||||
35
src/views/ai/music/index/mode/index.vue
Normal file
35
src/views/ai/music/index/mode/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<ContentWrap class="w-300px h-full mb-[0!important]">
|
||||
<el-radio-group v-model="generateMode" class="mb-15px">
|
||||
<el-radio-button value="desc"> 描述模式 </el-radio-button>
|
||||
<el-radio-button value="lyric"> 歌词模式 </el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- 描述模式/歌词模式 切换 -->
|
||||
<component :is="generateMode === 'desc' ? desc : lyric" ref="modeRef" />
|
||||
|
||||
<el-button type="primary" round class="w-full" @click="generateMusic"> 创作音乐 </el-button>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import desc from './desc.vue'
|
||||
import lyric from './lyric.vue'
|
||||
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
const emits = defineEmits(['generate-music'])
|
||||
|
||||
const generateMode = ref('lyric')
|
||||
|
||||
const modeRef = ref<Nullable<{ formData: Recordable }>>(null)
|
||||
|
||||
/*
|
||||
*@Description: 根据信息生成音乐
|
||||
*@MethodAuthor: xiaohong
|
||||
*@Date: 2024-06-27 16:40:16
|
||||
*/
|
||||
function generateMusic() {
|
||||
emits('generate-music', { formData: unref(modeRef)?.formData })
|
||||
}
|
||||
</script>
|
||||
83
src/views/ai/music/index/mode/lyric.vue
Normal file
83
src/views/ai/music/index/mode/lyric.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<Title title="歌词" desc="自己编写歌词或使用Ai生成歌词,两节/8行效果最佳">
|
||||
<el-input
|
||||
v-model="formData.lyric"
|
||||
:autosize="{ minRows: 6, maxRows: 6}"
|
||||
resize="none"
|
||||
type="textarea"
|
||||
maxlength="1200"
|
||||
show-word-limit
|
||||
placeholder="请输入您自己的歌词"
|
||||
/>
|
||||
</Title>
|
||||
|
||||
<Title title="音乐风格">
|
||||
<el-space class="flex-wrap">
|
||||
<el-tag v-for="tag in tags" :key="tag" round class="mb-8px">{{tag}}</el-tag>
|
||||
</el-space>
|
||||
|
||||
<el-button
|
||||
:type="showCustom ? 'primary': 'default'"
|
||||
round
|
||||
size="small"
|
||||
class="mb-6px"
|
||||
@click="showCustom = !showCustom"
|
||||
>自定义风格
|
||||
</el-button>
|
||||
</Title>
|
||||
|
||||
<Title v-show="showCustom" desc="描述您想要的音乐风格,Suno无法识别艺术家的名字,但可以理解流派和氛围" class="-mt-12px">
|
||||
<el-input
|
||||
v-model="formData.style"
|
||||
:autosize="{ minRows: 4, maxRows: 4}"
|
||||
resize="none"
|
||||
type="textarea"
|
||||
maxlength="256"
|
||||
show-word-limit
|
||||
placeholder="输入音乐风格(英文)"
|
||||
/>
|
||||
</Title>
|
||||
|
||||
<Title title="音乐/歌曲名称">
|
||||
<el-input v-model="formData.name" placeholder="请输入音乐/歌曲名称"/>
|
||||
</Title>
|
||||
|
||||
<Title title="版本">
|
||||
<el-select v-model="formData.version" placeholder="请选择">
|
||||
<el-option
|
||||
v-for="item in [{
|
||||
value: '3',
|
||||
label: 'V3'
|
||||
}, {
|
||||
value: '2',
|
||||
label: 'V2'
|
||||
}]"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</Title>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Title from '../title/index.vue'
|
||||
defineOptions({ name: 'Lyric' })
|
||||
|
||||
const tags = ['rock', 'punk', 'jazz', 'soul', 'country', 'kidsmusic', 'pop']
|
||||
|
||||
const showCustom = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
lyric: '',
|
||||
style: '',
|
||||
name: '',
|
||||
version: ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
formData
|
||||
})
|
||||
</script>
|
||||
25
src/views/ai/music/index/title/index.vue
Normal file
25
src/views/ai/music/index/title/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="mb-12px">
|
||||
<div class="flex text-[var(--el-text-color-primary)] justify-between items-center">
|
||||
<span>{{title}}</span>
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
<div class="text-[var(--el-text-color-secondary)] text-12px my-8px">
|
||||
{{desc}}
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'Index' })
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String
|
||||
},
|
||||
desc: {
|
||||
type: String
|
||||
}
|
||||
})
|
||||
</script>
|
||||
294
src/views/ai/music/manager/index.vue
Normal file
294
src/views/ai/music/manager/index.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<doc-alert title="AI 音乐创作" url="https://doc.iocoder.cn/ai/music/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-select
|
||||
v-model="queryParams.userId"
|
||||
clearable
|
||||
placeholder="请输入用户编号"
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in userList"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="音乐名称" prop="title">
|
||||
<el-input
|
||||
v-model="queryParams.title"
|
||||
placeholder="请输入音乐名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="音乐状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择音乐状态"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.AI_MUSIC_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="生成模式" prop="generateMode">
|
||||
<el-select
|
||||
v-model="queryParams.generateMode"
|
||||
placeholder="请选择生成模式"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.AI_GENERATE_MODE)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否发布" prop="publicStatus">
|
||||
<el-select
|
||||
v-model="queryParams.publicStatus"
|
||||
placeholder="请选择是否发布"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
|
||||
<el-table-column label="音乐名称" align="center" prop="title" width="180px" fixed="left" />
|
||||
<el-table-column label="用户" align="center" prop="userId" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="音乐状态" align="center" prop="status" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_MUSIC_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型" align="center" prop="model" width="180" />
|
||||
<el-table-column label="内容" align="center" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="row.audioUrl?.length > 0"
|
||||
type="primary"
|
||||
:href="row.audioUrl"
|
||||
target="_blank"
|
||||
>
|
||||
音乐
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="row.videoUrl?.length > 0"
|
||||
type="primary"
|
||||
:href="row.videoUrl"
|
||||
target="_blank"
|
||||
class="!pl-5px"
|
||||
>
|
||||
视频
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="row.imageUrl?.length > 0"
|
||||
type="primary"
|
||||
:href="row.imageUrl"
|
||||
target="_blank"
|
||||
class="!pl-5px"
|
||||
>
|
||||
封面
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长(秒)" align="center" prop="duration" width="100" />
|
||||
<el-table-column label="提示词" align="center" prop="prompt" width="180" />
|
||||
<el-table-column label="歌词" align="center" prop="lyric" width="180" />
|
||||
<el-table-column label="描述" align="center" prop="gptDescriptionPrompt" width="180" />
|
||||
<el-table-column label="生成模式" align="center" prop="generateMode" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_GENERATE_MODE" :value="scope.row.generateMode" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="风格标签" align="center" prop="tags" width="180">
|
||||
<template #default="scope">
|
||||
<el-tag v-for="tag in scope.row.tags" :key="tag" round class="ml-2px">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否发布" align="center" prop="publicStatus">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.publicStatus"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
@change="handleUpdatePublicStatusChange(scope.row)"
|
||||
:disabled="scope.row.status !== AiMusicStatusEnum.SUCCESS"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务编号" align="center" prop="taskId" width="180" />
|
||||
<el-table-column label="错误信息" align="center" prop="errorMessage" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:music:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { MusicApi, MusicVO } from '@/api/ai/music'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import { AiMusicStatusEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
/** AI 音乐 列表 */
|
||||
defineOptions({ name: 'AiMusicManager' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<MusicVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
title: undefined,
|
||||
status: undefined,
|
||||
generateMode: undefined,
|
||||
createTime: [],
|
||||
publicStatus: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const userList = ref<UserApi.UserVO[]>([]) // 用户列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await MusicApi.getMusicPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await MusicApi.deleteMusic(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 修改是否发布 */
|
||||
const handleUpdatePublicStatusChange = async (row: MusicVO) => {
|
||||
try {
|
||||
// 修改状态的二次确认
|
||||
const text = row.publicStatus ? '公开' : '私有'
|
||||
await message.confirm('确认要"' + text + '"该音乐吗?')
|
||||
// 发起修改状态
|
||||
await MusicApi.updateMusic({
|
||||
id: row.id,
|
||||
publicStatus: row.publicStatus
|
||||
})
|
||||
await getList()
|
||||
} catch {
|
||||
row.publicStatus = !row.publicStatus
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
getList()
|
||||
// 获得用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
})
|
||||
</script>
|
||||
470
src/views/ai/utils/constants.ts
Normal file
470
src/views/ai/utils/constants.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Created by 芋道源码
|
||||
*
|
||||
* AI 枚举类
|
||||
*
|
||||
* 问题:为什么不放在 src/utils/constants.ts 呢?
|
||||
* 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/constants.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* AI 平台的枚举
|
||||
*/
|
||||
export const AiPlatformEnum = {
|
||||
TONG_YI: 'TongYi', // 阿里
|
||||
YI_YAN: 'YiYan', // 百度
|
||||
DEEP_SEEK: 'DeepSeek', // DeepSeek
|
||||
ZHI_PU: 'ZhiPu', // 智谱 AI
|
||||
XING_HUO: 'XingHuo', // 讯飞
|
||||
SiliconFlow: 'SiliconFlow', // 硅基流动
|
||||
OPENAI: 'OpenAI',
|
||||
Ollama: 'Ollama',
|
||||
STABLE_DIFFUSION: 'StableDiffusion', // Stability AI
|
||||
MIDJOURNEY: 'Midjourney', // Midjourney
|
||||
SUNO: 'Suno' // Suno AI
|
||||
}
|
||||
|
||||
export const AiModelTypeEnum = {
|
||||
CHAT: 1, // 聊天
|
||||
IMAGE: 2, // 图像
|
||||
VOICE: 3, // 音频
|
||||
VIDEO: 4, // 视频
|
||||
EMBEDDING: 5, // 向量
|
||||
RERANK: 6 // 重排
|
||||
}
|
||||
|
||||
export const OtherPlatformEnum: ImageModelVO[] = [
|
||||
{
|
||||
key: AiPlatformEnum.TONG_YI,
|
||||
name: '通义万相'
|
||||
},
|
||||
{
|
||||
key: AiPlatformEnum.YI_YAN,
|
||||
name: '百度千帆'
|
||||
},
|
||||
{
|
||||
key: AiPlatformEnum.ZHI_PU,
|
||||
name: '智谱 AI'
|
||||
},
|
||||
{
|
||||
key: AiPlatformEnum.SiliconFlow,
|
||||
name: '硅基流动'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* AI 图像生成状态的枚举
|
||||
*/
|
||||
export const AiImageStatusEnum = {
|
||||
IN_PROGRESS: 10, // 进行中
|
||||
SUCCESS: 20, // 已完成
|
||||
FAIL: 30 // 已失败
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 音乐生成状态的枚举
|
||||
*/
|
||||
export const AiMusicStatusEnum = {
|
||||
IN_PROGRESS: 10, // 进行中
|
||||
SUCCESS: 20, // 已完成
|
||||
FAIL: 30 // 已失败
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 写作类型的枚举
|
||||
*/
|
||||
export enum AiWriteTypeEnum {
|
||||
WRITING = 1, // 撰写
|
||||
REPLY // 回复
|
||||
}
|
||||
|
||||
// 表格展示对照map
|
||||
export const AiWriteTypeTableRender = {
|
||||
[AiWriteTypeEnum.WRITING]: '撰写',
|
||||
[AiWriteTypeEnum.REPLY]: '回复'
|
||||
}
|
||||
|
||||
// ========== 【图片 UI】相关的枚举 ==========
|
||||
|
||||
export const ImageHotWords = [
|
||||
'中国旗袍',
|
||||
'古装美女',
|
||||
'卡通头像',
|
||||
'机甲战士',
|
||||
'童话小屋',
|
||||
'中国长城'
|
||||
] // 图片热词
|
||||
|
||||
export const ImageHotEnglishWords = [
|
||||
'Chinese Cheongsam',
|
||||
'Ancient Beauty',
|
||||
'Cartoon Avatar',
|
||||
'Mech Warrior',
|
||||
'Fairy Tale Cottage',
|
||||
'The Great Wall of China'
|
||||
] // 图片热词(英文)
|
||||
|
||||
export interface ImageModelVO {
|
||||
key: string
|
||||
name: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export const StableDiffusionSamplers: ImageModelVO[] = [
|
||||
{
|
||||
key: 'DDIM',
|
||||
name: 'DDIM'
|
||||
},
|
||||
{
|
||||
key: 'DDPM',
|
||||
name: 'DDPM'
|
||||
},
|
||||
{
|
||||
key: 'K_DPMPP_2M',
|
||||
name: 'K_DPMPP_2M'
|
||||
},
|
||||
{
|
||||
key: 'K_DPMPP_2S_ANCESTRAL',
|
||||
name: 'K_DPMPP_2S_ANCESTRAL'
|
||||
},
|
||||
{
|
||||
key: 'K_DPM_2',
|
||||
name: 'K_DPM_2'
|
||||
},
|
||||
{
|
||||
key: 'K_DPM_2_ANCESTRAL',
|
||||
name: 'K_DPM_2_ANCESTRAL'
|
||||
},
|
||||
{
|
||||
key: 'K_EULER',
|
||||
name: 'K_EULER'
|
||||
},
|
||||
{
|
||||
key: 'K_EULER_ANCESTRAL',
|
||||
name: 'K_EULER_ANCESTRAL'
|
||||
},
|
||||
{
|
||||
key: 'K_HEUN',
|
||||
name: 'K_HEUN'
|
||||
},
|
||||
{
|
||||
key: 'K_LMS',
|
||||
name: 'K_LMS'
|
||||
}
|
||||
]
|
||||
|
||||
export const StableDiffusionStylePresets: ImageModelVO[] = [
|
||||
{
|
||||
key: '3d-model',
|
||||
name: '3d-model'
|
||||
},
|
||||
{
|
||||
key: 'analog-film',
|
||||
name: 'analog-film'
|
||||
},
|
||||
{
|
||||
key: 'anime',
|
||||
name: 'anime'
|
||||
},
|
||||
{
|
||||
key: 'cinematic',
|
||||
name: 'cinematic'
|
||||
},
|
||||
{
|
||||
key: 'comic-book',
|
||||
name: 'comic-book'
|
||||
},
|
||||
{
|
||||
key: 'digital-art',
|
||||
name: 'digital-art'
|
||||
},
|
||||
{
|
||||
key: 'enhance',
|
||||
name: 'enhance'
|
||||
},
|
||||
{
|
||||
key: 'fantasy-art',
|
||||
name: 'fantasy-art'
|
||||
},
|
||||
{
|
||||
key: 'isometric',
|
||||
name: 'isometric'
|
||||
},
|
||||
{
|
||||
key: 'line-art',
|
||||
name: 'line-art'
|
||||
},
|
||||
{
|
||||
key: 'low-poly',
|
||||
name: 'low-poly'
|
||||
},
|
||||
{
|
||||
key: 'modeling-compound',
|
||||
name: 'modeling-compound'
|
||||
},
|
||||
// neon-punk origami photographic pixel-art tile-texture
|
||||
{
|
||||
key: 'neon-punk',
|
||||
name: 'neon-punk'
|
||||
},
|
||||
{
|
||||
key: 'origami',
|
||||
name: 'origami'
|
||||
},
|
||||
{
|
||||
key: 'photographic',
|
||||
name: 'photographic'
|
||||
},
|
||||
{
|
||||
key: 'pixel-art',
|
||||
name: 'pixel-art'
|
||||
},
|
||||
{
|
||||
key: 'tile-texture',
|
||||
name: 'tile-texture'
|
||||
}
|
||||
]
|
||||
|
||||
export const StableDiffusionClipGuidancePresets: ImageModelVO[] = [
|
||||
{
|
||||
key: 'NONE',
|
||||
name: 'NONE'
|
||||
},
|
||||
{
|
||||
key: 'FAST_BLUE',
|
||||
name: 'FAST_BLUE'
|
||||
},
|
||||
{
|
||||
key: 'FAST_GREEN',
|
||||
name: 'FAST_GREEN'
|
||||
},
|
||||
{
|
||||
key: 'SIMPLE',
|
||||
name: 'SIMPLE'
|
||||
},
|
||||
{
|
||||
key: 'SLOW',
|
||||
name: 'SLOW'
|
||||
},
|
||||
{
|
||||
key: 'SLOWER',
|
||||
name: 'SLOWER'
|
||||
},
|
||||
{
|
||||
key: 'SLOWEST',
|
||||
name: 'SLOWEST'
|
||||
}
|
||||
]
|
||||
|
||||
export const Dall3Models: ImageModelVO[] = [
|
||||
{
|
||||
key: 'dall-e-3',
|
||||
name: 'DALL·E 3',
|
||||
image: `/src/assets/ai/dall2.jpg`
|
||||
},
|
||||
{
|
||||
key: 'dall-e-2',
|
||||
name: 'DALL·E 2',
|
||||
image: `/src/assets/ai/dall3.jpg`
|
||||
}
|
||||
]
|
||||
|
||||
export const Dall3StyleList: ImageModelVO[] = [
|
||||
{
|
||||
key: 'vivid',
|
||||
name: '清晰',
|
||||
image: `/src/assets/ai/qingxi.jpg`
|
||||
},
|
||||
{
|
||||
key: 'natural',
|
||||
name: '自然',
|
||||
image: `/src/assets/ai/ziran.jpg`
|
||||
}
|
||||
]
|
||||
|
||||
export interface ImageSizeVO {
|
||||
key: string
|
||||
name?: string
|
||||
style: string
|
||||
width: string
|
||||
height: string
|
||||
}
|
||||
|
||||
export const Dall3SizeList: ImageSizeVO[] = [
|
||||
{
|
||||
key: '1024x1024',
|
||||
name: '1:1',
|
||||
width: '1024',
|
||||
height: '1024',
|
||||
style: 'width: 30px; height: 30px;background-color: #dcdcdc;'
|
||||
},
|
||||
{
|
||||
key: '1024x1792',
|
||||
name: '3:5',
|
||||
width: '1024',
|
||||
height: '1792',
|
||||
style: 'width: 30px; height: 50px;background-color: #dcdcdc;'
|
||||
},
|
||||
{
|
||||
key: '1792x1024',
|
||||
name: '5:3',
|
||||
width: '1792',
|
||||
height: '1024',
|
||||
style: 'width: 50px; height: 30px;background-color: #dcdcdc;'
|
||||
}
|
||||
]
|
||||
|
||||
export const MidjourneyModels: ImageModelVO[] = [
|
||||
{
|
||||
key: 'midjourney',
|
||||
name: 'MJ',
|
||||
image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png'
|
||||
},
|
||||
{
|
||||
key: 'niji',
|
||||
name: 'NIJI',
|
||||
image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png'
|
||||
}
|
||||
]
|
||||
|
||||
export const MidjourneySizeList: ImageSizeVO[] = [
|
||||
{
|
||||
key: '1:1',
|
||||
width: '1',
|
||||
height: '1',
|
||||
style: 'width: 30px; height: 30px;background-color: #dcdcdc;'
|
||||
},
|
||||
{
|
||||
key: '3:4',
|
||||
width: '3',
|
||||
height: '4',
|
||||
style: 'width: 30px; height: 40px;background-color: #dcdcdc;'
|
||||
},
|
||||
{
|
||||
key: '4:3',
|
||||
width: '4',
|
||||
height: '3',
|
||||
style: 'width: 40px; height: 30px;background-color: #dcdcdc;'
|
||||
},
|
||||
{
|
||||
key: '9:16',
|
||||
width: '9',
|
||||
height: '16',
|
||||
style: 'width: 30px; height: 50px;background-color: #dcdcdc;'
|
||||
},
|
||||
{
|
||||
key: '16:9',
|
||||
width: '16',
|
||||
height: '9',
|
||||
style: 'width: 50px; height: 30px;background-color: #dcdcdc;'
|
||||
}
|
||||
]
|
||||
|
||||
export const MidjourneyVersions = [
|
||||
{
|
||||
value: '6.0',
|
||||
label: 'v6.0'
|
||||
},
|
||||
{
|
||||
value: '5.2',
|
||||
label: 'v5.2'
|
||||
},
|
||||
{
|
||||
value: '5.1',
|
||||
label: 'v5.1'
|
||||
},
|
||||
{
|
||||
value: '5.0',
|
||||
label: 'v5.0'
|
||||
},
|
||||
{
|
||||
value: '4.0',
|
||||
label: 'v4.0'
|
||||
}
|
||||
]
|
||||
|
||||
export const NijiVersionList = [
|
||||
{
|
||||
value: '5',
|
||||
label: 'v5'
|
||||
}
|
||||
]
|
||||
|
||||
// ========== 【写作 UI】相关的枚举 ==========
|
||||
|
||||
/** 写作点击示例时的数据 **/
|
||||
export const WriteExample = {
|
||||
write: {
|
||||
prompt: 'vue',
|
||||
data: 'Vue.js 是一种用于构建用户界面的渐进式 JavaScript 框架。它的核心库只关注视图层,易于上手,同时也便于与其他库或已有项目整合。\n\nVue.js 的特点包括:\n- 响应式的数据绑定:Vue.js 会自动将数据与 DOM 同步,使得状态管理变得更加简单。\n- 组件化:Vue.js 允许开发者通过小型、独立和通常可复用的组件构建大型应用。\n- 虚拟 DOM:Vue.js 使用虚拟 DOM 实现快速渲染,提高了性能。\n\n在 Vue.js 中,一个典型的应用结构可能包括:\n1. 根实例:每个 Vue 应用都需要一个根实例作为入口点。\n2. 组件系统:可以创建自定义的可复用组件。\n3. 指令:特殊的带有前缀 v- 的属性,为 DOM 元素提供特殊的行为。\n4. 插值:用于文本内容,将数据动态地插入到 HTML。\n5. 计算属性和侦听器:用于处理数据的复杂逻辑和响应数据变化。\n6. 条件渲染:根据条件决定元素的渲染。\n7. 列表渲染:用于显示列表数据。\n8. 事件处理:响应用户交互。\n9. 表单输入绑定:处理表单输入和验证。\n10. 组件生命周期钩子:在组件的不同阶段执行特定的函数。\n\nVue.js 还提供了官方的路由器 Vue Router 和状态管理库 Vuex,以支持构建复杂的单页应用(SPA)。\n\n在开发过程中,开发者通常会使用 Vue CLI,这是一个强大的命令行工具,用于快速生成 Vue 项目脚手架,集成了诸如 Babel、Webpack 等现代前端工具,以及热重载、代码检测等开发体验优化功能。\n\nVue.js 的生态系统还包括大量的第三方库和插件,如 Vuetify(UI 组件库)、Vue Test Utils(测试工具)等,这些都极大地丰富了 Vue.js 的开发生态。\n\n总的来说,Vue.js 是一个灵活、高效的前端框架,适合从小型项目到大型企业级应用的开发。它的易用性、灵活性和强大的社区支持使其成为许多开发者的首选框架之一。'
|
||||
},
|
||||
reply: {
|
||||
originalContent: '领导,我想请假',
|
||||
prompt: '不批',
|
||||
data: '您的请假申请已收悉,经核实和考虑,暂时无法批准您的请假申请。\n\n如有特殊情况或紧急事务,请及时与我联系。\n\n祝工作顺利。\n\n谢谢。'
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 【思维导图 UI】相关的枚举 ==========
|
||||
|
||||
/** 思维导图已有内容生成示例 **/
|
||||
export const MindMapContentExample = `# Java 技术栈
|
||||
|
||||
## 核心技术
|
||||
### Java SE
|
||||
### Java EE
|
||||
|
||||
## 框架
|
||||
### Spring
|
||||
#### Spring Boot
|
||||
#### Spring MVC
|
||||
#### Spring Data
|
||||
### Hibernate
|
||||
### MyBatis
|
||||
|
||||
## 构建工具
|
||||
### Maven
|
||||
### Gradle
|
||||
|
||||
## 版本控制
|
||||
### Git
|
||||
### SVN
|
||||
|
||||
## 测试工具
|
||||
### JUnit
|
||||
### Mockito
|
||||
### Selenium
|
||||
|
||||
## 应用服务器
|
||||
### Tomcat
|
||||
### Jetty
|
||||
### WildFly
|
||||
|
||||
## 数据库
|
||||
### MySQL
|
||||
### PostgreSQL
|
||||
### Oracle
|
||||
### MongoDB
|
||||
|
||||
## 消息队列
|
||||
### Kafka
|
||||
### RabbitMQ
|
||||
### ActiveMQ
|
||||
|
||||
## 微服务
|
||||
### Spring Cloud
|
||||
### Dubbo
|
||||
|
||||
## 容器化
|
||||
### Docker
|
||||
### Kubernetes
|
||||
|
||||
## 云服务
|
||||
### AWS
|
||||
### Azure
|
||||
### Google Cloud
|
||||
|
||||
## 开发工具
|
||||
### IntelliJ IDEA
|
||||
### Eclipse
|
||||
### Visual Studio Code`
|
||||
13
src/views/ai/utils/utils.ts
Normal file
13
src/views/ai/utils/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Created by 芋道源码
|
||||
*
|
||||
* AI 枚举类
|
||||
*
|
||||
* 问题:为什么不放在 src/utils/common-utils.ts 呢?
|
||||
* 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/common-utils.ts
|
||||
*/
|
||||
|
||||
/** 判断字符串是否包含中文 */
|
||||
export const hasChinese = (str: string) => {
|
||||
return /[\u4e00-\u9fa5]/.test(str)
|
||||
}
|
||||
54
src/views/ai/workflow/form/BasicInfo.vue
Normal file
54
src/views/ai/workflow/form/BasicInfo.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :model="modelData" :rules="formRules" label-width="120px">
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="流程标识" prop="code">
|
||||
<el-input v-model="modelData.code" placeholder="请输入流程标识" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input v-model="modelData.name" placeholder="请输入流程名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="modelData.status" placeholder="请选择状态">
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="modelData.remark" :rows="2" type="textarea" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { FormRules } from 'element-plus'
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
|
||||
const modelData = defineModel<any>()
|
||||
|
||||
const formRef = ref() // 表单 Ref
|
||||
const formRules = reactive<FormRules>({
|
||||
code: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'change' }]
|
||||
})
|
||||
|
||||
/** 表单校验 */
|
||||
const validate = async () => {
|
||||
await formRef.value?.validate()
|
||||
}
|
||||
defineExpose({
|
||||
validate
|
||||
})
|
||||
</script>
|
||||
250
src/views/ai/workflow/form/WorkflowDesign.vue
Normal file
250
src/views/ai/workflow/form/WorkflowDesign.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="relative" style="width: 100%; height: 700px">
|
||||
<Tinyflow
|
||||
v-if="workflowData"
|
||||
ref="tinyflowRef"
|
||||
:className="'custom-class'"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
:data="workflowData"
|
||||
:provider="provider"
|
||||
/>
|
||||
<div class="absolute top-30px right-30px">
|
||||
<el-button @click="testWorkflowModel" type="primary" v-hasPermi="['ai:workflow:test']">
|
||||
测试
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 测试窗口 -->
|
||||
<el-drawer v-model="showTestDrawer" title="工作流测试" :modal="false">
|
||||
<fieldset>
|
||||
<legend class="ml-15px"><h3>运行参数配置</h3></legend>
|
||||
<div class="p-20px">
|
||||
<div
|
||||
class="flex justify-around mb-10px"
|
||||
v-for="(param, index) in params4Test"
|
||||
:key="index"
|
||||
>
|
||||
<el-select class="w-200px!" v-model="param.key" placeholder="参数名">
|
||||
<el-option
|
||||
v-for="(value, key) in paramsOfStartNode"
|
||||
:key="key"
|
||||
:label="value?.description || key"
|
||||
:value="key"
|
||||
:disabled="!!value?.disabled"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input class="w-200px!" v-model="param.value" placeholder="参数值" />
|
||||
<el-button type="danger" plain :icon="Delete" circle @click="removeParam(index)" />
|
||||
</div>
|
||||
<!-- TODO @lesan:是不是不用添加和删除参数,直接把必填和选填列出来,然后加上参数校验? -->
|
||||
<el-button type="primary" plain @click="addParam">添加参数</el-button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="mt-20px bg-#f8f9fa">
|
||||
<legend class="ml-15px"><h3>运行结果</h3></legend>
|
||||
<div class="p-20px">
|
||||
<div v-if="loading"> <el-text type="primary">执行中...</el-text></div>
|
||||
<div v-else-if="error">
|
||||
<el-text type="danger">{{ error }}</el-text>
|
||||
</div>
|
||||
<pre v-else-if="testResult" class="result-content"
|
||||
>{{ JSON.stringify(testResult, null, 2) }}
|
||||
</pre>
|
||||
<div v-else> <el-text type="info">点击运行查看结果</el-text> </div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<el-button class="mt-20px w-100%" size="large" type="success" @click="goRun">
|
||||
运行流程
|
||||
</el-button>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Tinyflow from '@/components/Tinyflow/Tinyflow.vue'
|
||||
import * as WorkflowApi from '@/api/ai/workflow'
|
||||
// TODO @lesan:要不使用 ICon 哪个组件哈
|
||||
import { Delete } from '@element-plus/icons-vue'
|
||||
|
||||
defineProps<{
|
||||
provider: any
|
||||
}>()
|
||||
|
||||
const tinyflowRef = ref()
|
||||
const workflowData = inject('workflowData') as Ref
|
||||
const showTestDrawer = ref(false)
|
||||
const params4Test = ref([])
|
||||
const paramsOfStartNode = ref({})
|
||||
const testResult = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
/** 展示工作流测试抽屉 */
|
||||
const testWorkflowModel = () => {
|
||||
showTestDrawer.value = !showTestDrawer.value
|
||||
}
|
||||
|
||||
/** 运行流程 */
|
||||
const goRun = async () => {
|
||||
try {
|
||||
const val = tinyflowRef.value.getData()
|
||||
loading.value = true
|
||||
error.value = null
|
||||
testResult.value = null
|
||||
/// 查找start节点
|
||||
const startNode = getStartNode()
|
||||
|
||||
// 获取参数定义
|
||||
const parameters = startNode.data?.parameters || []
|
||||
const paramDefinitions = {}
|
||||
parameters.forEach((param) => {
|
||||
paramDefinitions[param.name] = param.dataType
|
||||
})
|
||||
|
||||
// 参数类型转换
|
||||
const convertedParams = {}
|
||||
for (const { key, value } of params4Test.value) {
|
||||
const paramKey = key.trim()
|
||||
if (!paramKey) continue
|
||||
|
||||
let dataType = paramDefinitions[paramKey]
|
||||
if (!dataType) {
|
||||
dataType = 'String'
|
||||
}
|
||||
|
||||
try {
|
||||
convertedParams[paramKey] = convertParamValue(value, dataType)
|
||||
} catch (e) {
|
||||
throw new Error(`参数 ${paramKey} 转换失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
graph: JSON.stringify(val),
|
||||
params: convertedParams
|
||||
}
|
||||
|
||||
const response = await WorkflowApi.testWorkflow(data)
|
||||
testResult.value = response
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.message || '运行失败,请检查参数和网络连接'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听测试抽屉的开启,获取开始节点参数列表 */
|
||||
watch(showTestDrawer, (value) => {
|
||||
if (!value) return
|
||||
|
||||
/// 查找start节点
|
||||
const startNode = getStartNode()
|
||||
|
||||
// 获取参数定义
|
||||
const parameters = startNode.data?.parameters || []
|
||||
const paramDefinitions = {}
|
||||
|
||||
// 加入参数选项方便用户添加非必须参数
|
||||
parameters.forEach((param) => {
|
||||
paramDefinitions[param.name] = param
|
||||
})
|
||||
|
||||
function mergeIfRequiredButNotSet(target) {
|
||||
let needPushList = []
|
||||
for (let key in paramDefinitions) {
|
||||
let param = paramDefinitions[key]
|
||||
|
||||
if (param.required) {
|
||||
let item = target.find((item) => item.key === key)
|
||||
|
||||
if (!item) {
|
||||
needPushList.push({ key: param.name, value: param.defaultValue || '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
target.push(...needPushList)
|
||||
}
|
||||
// 自动装载需必填的参数
|
||||
mergeIfRequiredButNotSet(params4Test.value)
|
||||
|
||||
paramsOfStartNode.value = paramDefinitions
|
||||
})
|
||||
|
||||
/** 获取开始节点 */
|
||||
const getStartNode = () => {
|
||||
const val = tinyflowRef.value.getData()
|
||||
const startNode = val.nodes.find((node) => node.type === 'startNode')
|
||||
if (!startNode) {
|
||||
throw new Error('流程缺少开始节点')
|
||||
}
|
||||
return startNode
|
||||
}
|
||||
|
||||
/** 添加参数项 */
|
||||
const addParam = () => {
|
||||
params4Test.value.push({ key: '', value: '' })
|
||||
}
|
||||
|
||||
/** 删除参数项 */
|
||||
const removeParam = (index) => {
|
||||
params4Test.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 类型转换函数 */
|
||||
const convertParamValue = (value, dataType) => {
|
||||
if (value === '') return null // 空值处理
|
||||
|
||||
switch (dataType) {
|
||||
case 'String':
|
||||
return String(value)
|
||||
case 'Number':
|
||||
const num = Number(value)
|
||||
if (isNaN(num)) throw new Error('非数字格式')
|
||||
return num
|
||||
case 'Boolean':
|
||||
if (value.toLowerCase() === 'true') return true
|
||||
if (value.toLowerCase() === 'false') return false
|
||||
throw new Error('必须为 true/false')
|
||||
case 'Object':
|
||||
case 'Array':
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
throw new Error(`JSON格式错误: ${e.message}`)
|
||||
}
|
||||
default:
|
||||
throw new Error(`不支持的类型: ${dataType}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单校验 */
|
||||
const validate = async () => {
|
||||
try {
|
||||
// 获取最新的流程数据
|
||||
if (!workflowData.value) {
|
||||
throw new Error('请设计流程')
|
||||
}
|
||||
workflowData.value = tinyflowRef.value.getData()
|
||||
return true
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
defineExpose({
|
||||
validate
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.result-content {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
font-family: Monaco, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
240
src/views/ai/workflow/form/index.vue
Normal file
240
src/views/ai/workflow/form/index.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<div class="mx-auto">
|
||||
<!-- 头部导航栏 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 h-50px bg-white border-bottom z-10 flex items-center px-20px"
|
||||
>
|
||||
<!-- 左侧标题 -->
|
||||
<div class="w-200px flex items-center overflow-hidden">
|
||||
<Icon icon="ep:arrow-left" class="cursor-pointer flex-shrink-0" @click="handleBack" />
|
||||
<span class="ml-10px text-16px truncate" :title="formData.name || '创建流程'">
|
||||
{{ formData.name || '创建流程' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 步骤条 -->
|
||||
<div class="flex-1 flex items-center justify-center h-full">
|
||||
<div class="w-400px flex items-center justify-between h-full">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center cursor-pointer mx-15px relative h-full"
|
||||
:class="[
|
||||
currentStep === index
|
||||
? 'text-[#3473ff] border-[#3473ff] border-b-2 border-b-solid'
|
||||
: 'text-gray-500'
|
||||
]"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<div
|
||||
class="w-28px h-28px rounded-full flex items-center justify-center mr-8px border-2 border-solid text-15px"
|
||||
:class="[
|
||||
currentStep === index
|
||||
? 'bg-[#3473ff] text-white border-[#3473ff]'
|
||||
: 'border-gray-300 bg-white text-gray-500'
|
||||
]"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span class="text-16px font-bold whitespace-nowrap">{{ step.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧按钮 -->
|
||||
<div class="w-200px flex items-center justify-end gap-2">
|
||||
<el-button type="primary" @click="handleSave"> 保 存 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="mt-50px">
|
||||
<!-- 第一步:基本信息 -->
|
||||
<div v-if="currentStep === 0" class="mx-auto w-560px">
|
||||
<BasicInfo v-model="formData" ref="basicInfoRef" />
|
||||
</div>
|
||||
|
||||
<!-- 第二步:工作流设计 -->
|
||||
<WorkflowDesign
|
||||
v-if="currentStep === 1"
|
||||
v-model="formData"
|
||||
:provider="llmProvider"
|
||||
ref="workflowDesignRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import * as WorkflowApi from '@/api/ai/workflow'
|
||||
import BasicInfo from './BasicInfo.vue'
|
||||
import WorkflowDesign from './WorkflowDesign.vue'
|
||||
import { ModelApi } from '@/api/ai/model/model'
|
||||
import { AiModelTypeEnum } from '@/views/ai/utils/constants'
|
||||
|
||||
const router = useRouter()
|
||||
const { delView } = useTagsViewStore()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
|
||||
const basicInfoRef = ref()
|
||||
const workflowDesignRef = ref()
|
||||
|
||||
const validateBasic = async () => {
|
||||
await basicInfoRef.value?.validate()
|
||||
}
|
||||
const validateWorkflow = async () => {
|
||||
await workflowDesignRef.value?.validate()
|
||||
}
|
||||
|
||||
const currentStep = ref(-1)
|
||||
const steps = [
|
||||
{ title: '基本信息', validator: validateBasic },
|
||||
{ title: '工作流设计', validator: validateWorkflow }
|
||||
]
|
||||
|
||||
const formData: any = ref({
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
remark: '',
|
||||
graph: '',
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
const llmProvider = ref<any>([])
|
||||
const workflowData = ref<any>({})
|
||||
provide('workflowData', workflowData)
|
||||
|
||||
/** 初始化数据 */
|
||||
const actionType = route.params.type as string
|
||||
const initData = async () => {
|
||||
// 编辑情况下,需要加载工作流配置
|
||||
if (actionType === 'update') {
|
||||
const workflowId = route.params.id as string
|
||||
formData.value = await WorkflowApi.getWorkflow(workflowId)
|
||||
workflowData.value = JSON.parse(formData.value.graph)
|
||||
}
|
||||
|
||||
// 加载模型列表
|
||||
const models = await ModelApi.getModelSimpleList(AiModelTypeEnum.CHAT)
|
||||
llmProvider.value = {
|
||||
llm: () =>
|
||||
models.map(({ id, name }) => ({
|
||||
value: id,
|
||||
label: name
|
||||
})),
|
||||
knowledge: () => [],
|
||||
internal: () => []
|
||||
}
|
||||
// TODO @lesan:知识库(可以看下 knowledge)
|
||||
// TODO @lesan:搜索引擎(这个之前有个 pr 搞了,,,可能来接下)
|
||||
|
||||
// 设置当前步骤
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
/** 校验所有步骤数据是否完整 */
|
||||
const validateAllSteps = async () => {
|
||||
try {
|
||||
// 基本信息校验
|
||||
try {
|
||||
await validateBasic()
|
||||
} catch (error) {
|
||||
currentStep.value = 0
|
||||
throw new Error('请完善基本信息')
|
||||
}
|
||||
|
||||
// 工作流设计校验
|
||||
try {
|
||||
await validateWorkflow()
|
||||
} catch (error) {
|
||||
currentStep.value = 1
|
||||
throw new Error('请完善工作流信息')
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存操作 */
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 保存前校验所有步骤的数据
|
||||
await validateAllSteps()
|
||||
|
||||
// 更新表单数据
|
||||
const data = {
|
||||
...formData.value,
|
||||
graph: JSON.stringify(workflowData.value)
|
||||
}
|
||||
if (actionType === 'update') {
|
||||
await WorkflowApi.updateWorkflow(data)
|
||||
} else {
|
||||
await WorkflowApi.createWorkflow(data)
|
||||
}
|
||||
|
||||
// 保存成功,提示并跳转到列表页
|
||||
message.success('保存成功')
|
||||
delView(unref(router.currentRoute))
|
||||
await router.push({ name: 'AiWorkflow' })
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error)
|
||||
message.warning(error.message || '请完善所有步骤的必填信息')
|
||||
}
|
||||
}
|
||||
|
||||
/** 步骤切换处理 */
|
||||
const handleStepClick = async (index: number) => {
|
||||
try {
|
||||
if (index !== 0) {
|
||||
await validateBasic()
|
||||
}
|
||||
if (index !== 1) {
|
||||
await validateWorkflow()
|
||||
}
|
||||
|
||||
// 切换步骤
|
||||
currentStep.value = index
|
||||
} catch (error) {
|
||||
console.error('步骤切换失败:', error)
|
||||
message.warning('请先完善当前步骤必填信息')
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回列表页 */
|
||||
const handleBack = () => {
|
||||
// 先删除当前页签
|
||||
delView(unref(router.currentRoute))
|
||||
// 跳转到列表页
|
||||
router.push({ name: 'AiWorkflow' })
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await initData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- TODO @lesan:可以用 cursor 搞成 unocss 哈 -->
|
||||
<style lang="scss" scoped>
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #3473ff;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: #3473ff;
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: #3473ff;
|
||||
}
|
||||
</style>
|
||||
193
src/views/ai/workflow/index.vue
Normal file
193
src/views/ai/workflow/index.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<!-- 搜索工作栏 -->
|
||||
<ContentWrap>
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="流程标识" prop="code">
|
||||
<el-input
|
||||
v-model="queryParams.code"
|
||||
placeholder="请输入流程标识"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入流程名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="状态" clearable class="!w-240px">
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['ai:workflow:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" /> 新增
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="编号" align="center" prop="id" />
|
||||
<el-table-column label="流程标识" align="center" prop="code" />
|
||||
<el-table-column label="流程名称" align="center" prop="name" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="备注" align="center" prop="remark" />
|
||||
<el-table-column label="状态" align="center" key="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['ai:workflow:update']"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:workflow:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 添加或修改工作流对话框 -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import * as WorkflowApi from '@/api/ai/workflow'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
|
||||
defineOptions({ name: 'AiWorkflow' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
const { push } = useRouter() // 路由
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
code: '',
|
||||
name: '',
|
||||
status: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await WorkflowApi.getWorkflowPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await WorkflowApi.deleteWorkflow(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const openForm = async (type: string, id?: number) => {
|
||||
if (type === 'create') {
|
||||
await push({ name: 'AiWorkflowCreate' })
|
||||
} else {
|
||||
await push({
|
||||
name: 'AiWorkflowUpdate',
|
||||
params: { id, type }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
213
src/views/ai/write/index/components/Left.vue
Normal file
213
src/views/ai/write/index/components/Left.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<!-- 定义 tab 组件:撰写/回复等 -->
|
||||
<DefineTab v-slot="{ active, text, itemClick }">
|
||||
<span
|
||||
:class="active ? 'text-black shadow-md' : 'hover:bg-[#DDDFE3]'"
|
||||
class="inline-block w-1/2 rounded-full cursor-pointer text-center leading-[30px] relative z-1 text-[5C6370] hover:text-black"
|
||||
@click="itemClick"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</DefineTab>
|
||||
<!-- 定义 label 组件:长度/格式/语气/语言等 -->
|
||||
<DefineLabel v-slot="{ label, hint, hintClick }">
|
||||
<h3 class="mt-5 mb-3 flex items-center justify-between text-[14px]">
|
||||
<span>{{ label }}</span>
|
||||
<span
|
||||
v-if="hint"
|
||||
class="flex items-center text-[12px] text-[#846af7] cursor-pointer select-none"
|
||||
@click="hintClick"
|
||||
>
|
||||
<Icon icon="ep:question-filled" />
|
||||
{{ hint }}
|
||||
</span>
|
||||
</h3>
|
||||
</DefineLabel>
|
||||
|
||||
<div class="flex flex-col" v-bind="$attrs">
|
||||
<!-- tab -->
|
||||
<div class="w-full pt-2 bg-[#f5f7f9] flex justify-center">
|
||||
<div class="w-[303px] rounded-full bg-[#DDDFE3] p-1 z-10">
|
||||
<div
|
||||
:class="
|
||||
selectedTab === AiWriteTypeEnum.REPLY && 'after:transform after:translate-x-[100%]'
|
||||
"
|
||||
class="flex items-center relative after:content-[''] after:block after:bg-white after:h-[30px] after:w-1/2 after:absolute after:top-0 after:left-0 after:transition-transform after:rounded-full"
|
||||
>
|
||||
<ReuseTab
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:active="tab.value === selectedTab"
|
||||
:itemClick="() => switchTab(tab.value)"
|
||||
:text="tab.text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-7 pb-2 flex-grow overflow-y-auto lg:block w-[380px] box-border bg-[#f5f7f9] h-full"
|
||||
>
|
||||
<div>
|
||||
<template v-if="selectedTab === 1">
|
||||
<ReuseLabel :hint-click="() => example('write')" hint="示例" label="写作内容" />
|
||||
<el-input
|
||||
v-model="formData.prompt"
|
||||
:maxlength="500"
|
||||
:rows="5"
|
||||
placeholder="请输入写作内容"
|
||||
showWordLimit
|
||||
type="textarea"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ReuseLabel :hint-click="() => example('reply')" hint="示例" label="原文" />
|
||||
<el-input
|
||||
v-model="formData.originalContent"
|
||||
:maxlength="500"
|
||||
:rows="5"
|
||||
placeholder="请输入原文"
|
||||
showWordLimit
|
||||
type="textarea"
|
||||
/>
|
||||
|
||||
<ReuseLabel label="回复内容" />
|
||||
<el-input
|
||||
v-model="formData.prompt"
|
||||
:maxlength="500"
|
||||
:rows="5"
|
||||
placeholder="请输入回复内容"
|
||||
showWordLimit
|
||||
type="textarea"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ReuseLabel label="长度" />
|
||||
<Tag v-model="formData.length" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LENGTH)" />
|
||||
<ReuseLabel label="格式" />
|
||||
<Tag v-model="formData.format" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_FORMAT)" />
|
||||
<ReuseLabel label="语气" />
|
||||
<Tag v-model="formData.tone" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_TONE)" />
|
||||
<ReuseLabel label="语言" />
|
||||
<Tag v-model="formData.language" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LANGUAGE)" />
|
||||
|
||||
<div class="flex items-center justify-center mt-3">
|
||||
<el-button :disabled="isWriting" @click="reset">重置</el-button>
|
||||
<el-button :loading="isWriting" color="#846af7" @click="submit">生成</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { createReusableTemplate } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
import Tag from './Tag.vue'
|
||||
import { WriteVO } from '@/api/ai/write'
|
||||
import { omit } from 'lodash-es'
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import { AiWriteTypeEnum, WriteExample } from '@/views/ai/utils/constants'
|
||||
|
||||
type TabType = WriteVO['type']
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
defineProps<{
|
||||
isWriting: boolean
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'submit', params: Partial<WriteVO>)
|
||||
(e: 'example', param: 'write' | 'reply')
|
||||
(e: 'reset')
|
||||
}>()
|
||||
|
||||
/** 点击示例的时候,将定义好的文章作为示例展示出来 **/
|
||||
const example = (type: 'write' | 'reply') => {
|
||||
formData.value = {
|
||||
...initData,
|
||||
...omit(WriteExample[type], ['data'])
|
||||
}
|
||||
emits('example', type)
|
||||
}
|
||||
|
||||
/** 重置,将表单值作为初选值 **/
|
||||
const reset = () => {
|
||||
formData.value = { ...initData }
|
||||
emits('reset')
|
||||
}
|
||||
|
||||
const selectedTab = ref<TabType>(AiWriteTypeEnum.WRITING)
|
||||
const tabs: {
|
||||
text: string
|
||||
value: TabType
|
||||
}[] = [
|
||||
{ text: '撰写', value: AiWriteTypeEnum.WRITING },
|
||||
{ text: '回复', value: AiWriteTypeEnum.REPLY }
|
||||
]
|
||||
const [DefineTab, ReuseTab] = createReusableTemplate<{
|
||||
active?: boolean
|
||||
text: string
|
||||
itemClick: () => void
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 可以在 template 里边定义可复用的组件,DefineLabel,ReuseLabel 是采用的解构赋值,都是 Vue 组件
|
||||
*
|
||||
* 直接通过组件的形式使用,<DefineLabel v-slot="{ label, hint, hintClick }"> 中间是需要复用的组件代码 <DefineLabel />,通过 <ReuseLabel /> 来使用定义的组件
|
||||
* DefineLabel 里边的 v-slot="{ label, hint, hintClick }"相当于是解构了组件的 prop,需要注意的是 boolean 类型,需要显式的赋值比如 <ReuseLabel :flag="true" />
|
||||
* 事件也得以 prop 形式传入,不能是 @event的形式,比如下面的 hintClick 需要<ReuseLabel :hintClick="() => { doSomething }"/>
|
||||
*
|
||||
* @see https://vueuse.org/createReusableTemplate
|
||||
*/
|
||||
const [DefineLabel, ReuseLabel] = createReusableTemplate<{
|
||||
label: string
|
||||
class?: string
|
||||
hint?: string
|
||||
hintClick?: () => void
|
||||
}>()
|
||||
|
||||
const initData: WriteVO = {
|
||||
type: 1,
|
||||
prompt: '',
|
||||
originalContent: '',
|
||||
tone: 1,
|
||||
language: 1,
|
||||
length: 1,
|
||||
format: 1
|
||||
}
|
||||
const formData = ref<WriteVO>({ ...initData })
|
||||
|
||||
/** 用来记录切换之前所填写的数据,切换的时候给赋值回来 **/
|
||||
const recordFormData = {} as Record<AiWriteTypeEnum, WriteVO>
|
||||
|
||||
/** 切换tab **/
|
||||
const switchTab = (value: TabType) => {
|
||||
if (value !== selectedTab.value) {
|
||||
// 保存之前的久数据
|
||||
recordFormData[selectedTab.value] = formData.value
|
||||
selectedTab.value = value
|
||||
// 将之前的旧数据赋值回来
|
||||
formData.value = { ...initData, ...recordFormData[value] }
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交写作 */
|
||||
const submit = () => {
|
||||
if (selectedTab.value === 2 && !formData.value.originalContent) {
|
||||
message.warning('请输入原文')
|
||||
return
|
||||
}
|
||||
if (!formData.value.prompt) {
|
||||
message.warning(`请输入${selectedTab.value === 1 ? '写作' : '回复'}内容`)
|
||||
return
|
||||
}
|
||||
emits('submit', {
|
||||
/** 撰写的时候没有 originalContent 字段**/
|
||||
...(selectedTab.value === 1 ? omit(formData.value, ['originalContent']) : formData.value),
|
||||
/** 使用选中 tab 值覆盖当前的 type 类型 **/
|
||||
type: selectedTab.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
120
src/views/ai/write/index/components/Right.vue
Normal file
120
src/views/ai/write/index/components/Right.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<el-card class="my-card h-full">
|
||||
<template #header>
|
||||
<h3 class="m-0 px-7 shrink-0 flex items-center justify-between">
|
||||
<span>预览</span>
|
||||
<!-- 展示在右上角 -->
|
||||
<el-button color="#846af7" v-show="showCopy" @click="copyContent" size="small">
|
||||
<template #icon>
|
||||
<Icon icon="ph:copy-bold" />
|
||||
</template>
|
||||
复制
|
||||
</el-button>
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<div ref="contentRef" class="hide-scroll-bar h-full box-border overflow-y-auto">
|
||||
<div class="w-full min-h-full relative flex-grow bg-white box-border p-3 sm:p-7">
|
||||
<!-- 终止生成内容的按钮 -->
|
||||
<el-button
|
||||
v-show="isWriting"
|
||||
class="absolute bottom-2 sm:bottom-5 left-1/2 -translate-x-1/2 z-36"
|
||||
@click="emits('stopStream')"
|
||||
size="small"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="material-symbols:stop" />
|
||||
</template>
|
||||
终止生成
|
||||
</el-button>
|
||||
<el-input
|
||||
id="inputId"
|
||||
type="textarea"
|
||||
v-model="compContent"
|
||||
autosize
|
||||
:input-style="{ boxShadow: 'none' }"
|
||||
resize="none"
|
||||
placeholder="生成的内容……"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { copied, copy } = useClipboard() // 粘贴板
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
// 生成的结果
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isWriting: {
|
||||
// 是否正在生成文章
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits(['update:content', 'stopStream'])
|
||||
|
||||
/** 通过计算属性,双向绑定,更改生成的内容,考虑到用户想要更改生成文章的情况 */
|
||||
const compContent = computed({
|
||||
get() {
|
||||
return props.content
|
||||
},
|
||||
set(val) {
|
||||
emits('update:content', val)
|
||||
}
|
||||
})
|
||||
|
||||
/** 滚动 */
|
||||
const contentRef = ref<HTMLDivElement>()
|
||||
defineExpose({
|
||||
scrollToBottom() {
|
||||
contentRef.value?.scrollTo(0, contentRef.value?.scrollHeight)
|
||||
}
|
||||
})
|
||||
|
||||
/** 点击复制的时候复制内容 */
|
||||
const showCopy = computed(() => props.content && !props.isWriting) // 是否展示复制按钮,在生成内容完成的时候展示
|
||||
const copyContent = () => {
|
||||
copy(props.content)
|
||||
}
|
||||
|
||||
/** 复制成功的时候 copied.value 为 true */
|
||||
watch(copied, (val) => {
|
||||
if (val) {
|
||||
message.success('复制成功')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hide-scroll-bar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.my-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
box-sizing: border-box;
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
@extend .hide-scroll-bar;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
src/views/ai/write/index/components/Tag.vue
Normal file
32
src/views/ai/write/index/components/Tag.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<!-- 标签选项 -->
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
<span
|
||||
v-for="tag in props.tags"
|
||||
:key="tag.value"
|
||||
class="tag mb-2 border-[2px] border-solid border-[#DDDFE3] px-2 leading-6 text-[12px] bg-[#DDDFE3] rounded-[4px] cursor-pointer"
|
||||
:class="modelValue === tag.value && '!border-[#846af7] text-[#846af7]'"
|
||||
@click="emits('update:modelValue', tag.value)"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tags: { label: string; value: string }[]
|
||||
modelValue: string
|
||||
[k: string]: any
|
||||
}>(),
|
||||
{
|
||||
tags: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
</script>
|
||||
<style scoped></style>
|
||||
78
src/views/ai/write/index/index.vue
Normal file
78
src/views/ai/write/index/index.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="absolute top-0 left-0 right-0 bottom-0 flex">
|
||||
<Left
|
||||
:is-writing="isWriting"
|
||||
class="h-full"
|
||||
@submit="submit"
|
||||
@reset="reset"
|
||||
@example="handleExampleClick"
|
||||
/>
|
||||
<Right
|
||||
:is-writing="isWriting"
|
||||
@stop-stream="stopStream"
|
||||
ref="rightRef"
|
||||
class="flex-grow"
|
||||
v-model:content="writeResult"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Left from './components/Left.vue'
|
||||
import Right from './components/Right.vue'
|
||||
import { WriteApi, WriteVO } from '@/api/ai/write'
|
||||
import { WriteExample } from '@/views/ai/utils/constants'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const writeResult = ref('') // 写作结果
|
||||
const isWriting = ref(false) // 是否正在写作中
|
||||
const abortController = ref<AbortController>() // // 写作进行中 abort 控制器(控制 stream 写作)
|
||||
|
||||
/** 停止 stream 生成 */
|
||||
const stopStream = () => {
|
||||
abortController.value?.abort()
|
||||
isWriting.value = false
|
||||
}
|
||||
|
||||
/** 执行写作 */
|
||||
const rightRef = ref<InstanceType<typeof Right>>()
|
||||
const submit = (data: WriteVO) => {
|
||||
abortController.value = new AbortController()
|
||||
writeResult.value = ''
|
||||
isWriting.value = true
|
||||
WriteApi.writeStream({
|
||||
data,
|
||||
onMessage: async (res) => {
|
||||
const { code, data, msg } = JSON.parse(res.data)
|
||||
if (code !== 0) {
|
||||
message.alert(`写作异常! ${msg}`)
|
||||
stopStream()
|
||||
return
|
||||
}
|
||||
writeResult.value = writeResult.value + data
|
||||
// 滚动到底部
|
||||
await nextTick()
|
||||
rightRef.value?.scrollToBottom()
|
||||
},
|
||||
ctrl: abortController.value,
|
||||
onClose: stopStream,
|
||||
onError: (error) => {
|
||||
console.error('写作异常', error)
|
||||
stopStream()
|
||||
// 需要抛出异常,禁止重试
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 点击示例触发 */
|
||||
const handleExampleClick = (type: keyof typeof WriteExample) => {
|
||||
writeResult.value = WriteExample[type].data
|
||||
}
|
||||
|
||||
/** 点击重置的时候清空写作的结果**/
|
||||
const reset = () => {
|
||||
writeResult.value = ''
|
||||
}
|
||||
</script>
|
||||
227
src/views/ai/write/manager/index.vue
Normal file
227
src/views/ai/write/manager/index.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<doc-alert title="AI 写作助手" url="https://doc.iocoder.cn/ai/write/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="用户编号" prop="userId">
|
||||
<el-select
|
||||
v-model="queryParams.userId"
|
||||
clearable
|
||||
placeholder="请输入用户编号"
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in userList"
|
||||
:key="item.id"
|
||||
:label="item.nickname"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="写作类型" prop="type">
|
||||
<el-select
|
||||
v-model="queryParams.type"
|
||||
placeholder="请选择写作类型"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.AI_WRITE_TYPE)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台" prop="platform">
|
||||
<el-select
|
||||
v-model="queryParams.platform"
|
||||
placeholder="请选择平台"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="编号" align="center" prop="id" width="120" fixed="left" />
|
||||
<el-table-column label="用户" align="center" prop="userId" width="180">
|
||||
<template #default="scope">
|
||||
<span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="写作类型" align="center" prop="type">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_WRITE_TYPE" :value="scope.row.type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" align="center" prop="platform" width="120">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型" align="center" prop="model" width="180" />
|
||||
<el-table-column
|
||||
label="生成内容提示"
|
||||
align="center"
|
||||
prop="prompt"
|
||||
width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="生成的内容" align="center" prop="generatedContent" width="180" />
|
||||
<el-table-column label="原文" align="center" prop="originalContent" width="180" />
|
||||
<el-table-column label="长度" align="center" prop="length">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_WRITE_LENGTH" :value="scope.row.length" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="格式" align="center" prop="format">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_WRITE_FORMAT" :value="scope.row.format" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="语气" align="center" prop="tone">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_WRITE_TONE" :value="scope.row.tone" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="语言" align="center" prop="language">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.AI_WRITE_LANGUAGE" :value="scope.row.language" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="错误信息" align="center" prop="errorMessage" />
|
||||
<el-table-column label="操作" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['ai:write:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { WriteApi, AiWritePageReqVO, AiWriteRespVo } from '@/api/ai/write'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
|
||||
/** AI 写作列表 */
|
||||
defineOptions({ name: 'AiWriteManager' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
const router = useRouter() // 路由
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<AiWriteRespVo[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive<AiWritePageReqVO>({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: undefined,
|
||||
type: undefined,
|
||||
platform: undefined,
|
||||
createTime: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const userList = ref<UserApi.UserVO[]>([]) // 用户列表
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await WriteApi.getWritePage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await WriteApi.deleteWrite(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(async () => {
|
||||
getList()
|
||||
// 获得用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
})
|
||||
</script>
|
||||
130
src/views/bpm/category/CategoryForm.vue
Normal file
130
src/views/bpm/category/CategoryForm.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<Dialog :title="dialogTitle" v-model="dialogVisible">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
v-loading="formLoading"
|
||||
>
|
||||
<el-form-item label="分类名" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入分类名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类标志" prop="code">
|
||||
<el-input v-model="formData.code" placeholder="请输入分类标志" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类描述" prop="description">
|
||||
<el-input v-model="formData.description" type="textarea" placeholder="请输入分类描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类排序" prop="sort">
|
||||
<el-input-number
|
||||
v-model="formData.sort"
|
||||
placeholder="请输入分类排序"
|
||||
class="!w-1/1"
|
||||
:precision="0"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
|
||||
/** BPM 流程分类 表单 */
|
||||
defineOptions({ name: 'CategoryForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
description: undefined,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
sort: undefined
|
||||
})
|
||||
const formRules = reactive({
|
||||
name: [{ required: true, message: '分类名不能为空', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '分类标志不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '分类状态不能为空', trigger: 'blur' }],
|
||||
sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await CategoryApi.getCategory(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
await formRef.value.validate()
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as CategoryVO
|
||||
if (formType.value === 'create') {
|
||||
await CategoryApi.createCategory(data)
|
||||
message.success(t('common.createSuccess'))
|
||||
} else {
|
||||
await CategoryApi.updateCategory(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
description: undefined,
|
||||
status: CommonStatusEnum.ENABLE,
|
||||
sort: undefined
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
@@ -1,53 +1,48 @@
|
||||
<template>
|
||||
<doc-alert title="异常处理(错误码)" url="https://doc.iocoder.cn/exception/" />
|
||||
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
|
||||
|
||||
<!-- 搜索工作栏 -->
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="90px"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="错误码类型" prop="type">
|
||||
<el-select v-model="queryParams.type" placeholder="请选择错误码类型" clearable>
|
||||
<el-form-item label="分类名" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入分类名"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类标志" prop="code">
|
||||
<el-input
|
||||
v-model="queryParams.code"
|
||||
placeholder="请输入分类标志"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类状态" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
placeholder="请选择分类状态"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_ERROR_CODE_TYPE)"
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="应用名" prop="applicationName">
|
||||
<el-input
|
||||
v-model="queryParams.applicationName"
|
||||
placeholder="请输入应用名"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="错误码编码" prop="code">
|
||||
<el-input
|
||||
v-model="queryParams.code"
|
||||
placeholder="请输入错误码编码"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="错误码提示" prop="message">
|
||||
<el-input
|
||||
v-model="queryParams.message"
|
||||
placeholder="请输入错误码提示"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
@@ -66,50 +61,41 @@
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['system:error-code:create']"
|
||||
v-hasPermi="['bpm:category:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||
</el-button>
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
@click="handleExport"
|
||||
:loading="exportLoading"
|
||||
v-hasPermi="['system:error-code:export']"
|
||||
>
|
||||
<Icon icon="ep:download" class="mr-5px" /> 导出
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="编号" align="center" prop="id" />
|
||||
<el-table-column label="类型" align="center" prop="type" width="80">
|
||||
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
|
||||
<el-table-column label="分类编号" align="center" prop="id" />
|
||||
<el-table-column label="分类名" align="center" prop="name" />
|
||||
<el-table-column label="分类标志" align="center" prop="code" />
|
||||
<el-table-column label="分类描述" align="center" prop="description" />
|
||||
<el-table-column label="分类状态" align="center" prop="status">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.SYSTEM_ERROR_CODE_TYPE" :value="scope.row.type" />
|
||||
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应用名" align="center" prop="applicationName" width="200" />
|
||||
<el-table-column label="错误码编码" align="center" prop="code" width="120" />
|
||||
<el-table-column label="错误码提示" align="center" prop="message" width="300" />
|
||||
<el-table-column label="备注" align="center" prop="memo" width="200" />
|
||||
<el-table-column label="分类排序" align="center" prop="sort" />
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
width="180px"
|
||||
/>
|
||||
<el-table-column label="操作" align="center" class-name="small-paddingfixed-width">
|
||||
<el-table-column label="操作" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['system:error-code:update']"
|
||||
v-hasPermi="['bpm:category:update']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
@@ -117,14 +103,14 @@
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['system:error-code:delete']"
|
||||
v-hasPermi="['bpm:category:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页组件 -->
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
@@ -134,41 +120,40 @@
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
<ErrorCodeForm ref="formRef" @success="getList" />
|
||||
<CategoryForm ref="formRef" @success="getList" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
<script setup lang="ts">
|
||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import download from '@/utils/download'
|
||||
import * as ErrorCodeApi from '@/api/system/errorCode'
|
||||
import ErrorCodeForm from './ErrorCodeForm.vue'
|
||||
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
||||
import CategoryForm from './CategoryForm.vue'
|
||||
|
||||
defineOptions({ name: 'SystemErrorCode' })
|
||||
/** BPM 流程分类 列表 */
|
||||
defineOptions({ name: 'BpmCategory' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
|
||||
const loading = ref(true) // 遮罩层
|
||||
const exportLoading = ref(false) // 导出遮罩层
|
||||
const total = ref(0) // 总条数
|
||||
const list = ref([]) // 错误码列表
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const list = ref<CategoryVO[]>([]) // 列表的数据
|
||||
const total = ref(0) // 列表的总页数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
type: undefined,
|
||||
applicationName: undefined,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
message: undefined,
|
||||
status: undefined,
|
||||
createTime: []
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
const exportLoading = ref(false) // 导出的加载中
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ErrorCodeApi.getErrorCodePage(queryParams)
|
||||
const data = await CategoryApi.getCategoryPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
@@ -199,28 +184,14 @@ const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
await ErrorCodeApi.deleteErrorCode(id)
|
||||
// 发起删除
|
||||
await CategoryApi.deleteCategory(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 导出按钮操作 */
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
// 导出的二次确认
|
||||
await message.exportConfirm()
|
||||
// 发起导出
|
||||
exportLoading.value = true
|
||||
const data = await ErrorCodeApi.excelErrorCode(queryParams)
|
||||
download.excel(data, '错误码.xls')
|
||||
} catch {
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
getList()
|
||||
@@ -1,14 +1,18 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<ContentWrap :body-style="{ padding: '0px' }" class="!mb-0">
|
||||
<!-- 表单设计器 -->
|
||||
<FcDesigner ref="designer" height="780px">
|
||||
<template #handle>
|
||||
<el-button round size="small" type="primary" @click="handleSave">
|
||||
<Icon class="mr-5px" icon="ep:plus" />
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</FcDesigner>
|
||||
<div
|
||||
class="h-[calc(100vh-var(--top-tool-height)-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-2px)]"
|
||||
>
|
||||
<fc-designer class="my-designer" ref="designer" :config="designerConfig">
|
||||
<template #handle>
|
||||
<el-button size="small" type="success" plain @click="handleSave">
|
||||
<Icon class="mr-5px" icon="ep:plus" />
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</fc-designer>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单保存的弹窗 -->
|
||||
@@ -22,7 +26,7 @@
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
@@ -45,16 +49,49 @@ import * as FormApi from '@/api/bpm/form'
|
||||
import FcDesigner from '@form-create/designer'
|
||||
import { encodeConf, encodeFields, setConfAndFields } from '@/utils/formCreate'
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
import { useFormCreateDesigner } from '@/components/FormCreate'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'BpmFormEditor' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息
|
||||
const route = useRoute() // 路由
|
||||
const { push, currentRoute } = useRouter() // 路由
|
||||
const { query } = useRoute() // 路由信息
|
||||
const { delView } = useTagsViewStore() // 视图操作
|
||||
|
||||
// 表单设计器配置
|
||||
const designerConfig = ref({
|
||||
switchType: [], // 是否可以切换组件类型,或者可以相互切换的字段
|
||||
autoActive: true, // 是否自动选中拖入的组件
|
||||
useTemplate: false, // 是否生成vue2语法的模板组件
|
||||
formOptions: {
|
||||
form: {
|
||||
labelWidth: '100px' // 设置默认的 label 宽度为 100px
|
||||
}
|
||||
}, // 定义表单配置默认值
|
||||
fieldReadonly: false, // 配置field是否可以编辑
|
||||
hiddenDragMenu: false, // 隐藏拖拽操作按钮
|
||||
hiddenDragBtn: false, // 隐藏拖拽按钮
|
||||
hiddenMenu: [], // 隐藏部分菜单
|
||||
hiddenItem: [], // 隐藏部分组件
|
||||
hiddenItemConfig: {}, // 隐藏组件的部分配置项
|
||||
disabledItemConfig: {}, // 禁用组件的部分配置项
|
||||
showSaveBtn: false, // 是否显示保存按钮
|
||||
showConfig: true, // 是否显示右侧的配置界面
|
||||
showBaseForm: true, // 是否显示组件的基础配置表单
|
||||
showControl: true, // 是否显示组件联动
|
||||
showPropsForm: true, // 是否显示组件的属性配置表单
|
||||
showEventForm: true, // 是否显示组件的事件配置表单
|
||||
showValidateForm: true, // 是否显示组件的验证配置表单
|
||||
showFormConfig: true, // 是否显示表单配置
|
||||
showInputData: true, // 是否显示录入按钮
|
||||
showDevice: true, // 是否显示多端适配选项
|
||||
appendConfigData: [] // 定义渲染规则所需的formData
|
||||
})
|
||||
const designer = ref() // 表单设计器
|
||||
useFormCreateDesigner(designer) // 表单设计器增强
|
||||
const dialogVisible = ref(false) // 弹窗是否展示
|
||||
const formLoading = ref(false) // 表单的加载中:提交的按钮禁用
|
||||
const formData = ref({
|
||||
@@ -115,5 +152,23 @@ onMounted(async () => {
|
||||
const data = await FormApi.getForm(id)
|
||||
formData.value = data
|
||||
setConfAndFields(designer, data.conf, data.fields)
|
||||
|
||||
if (route.query.type !== 'copy') {
|
||||
return
|
||||
}
|
||||
// 场景三: 复制表单
|
||||
const { id: foo, ...copied } = data
|
||||
formData.value = copied
|
||||
formData.value.name += '_copy'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.my-designer {
|
||||
._fc-l,
|
||||
._fc-m,
|
||||
._fc-r {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<template>
|
||||
<doc-alert title="审批接入(流程表单)" url="https://doc.iocoder.cn/bpm/use-bpm-form/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
@@ -57,7 +59,15 @@
|
||||
v-hasPermi="['bpm:form:update']"
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm(scope.row.id)"
|
||||
@click="openForm('copy', scope.row.id)"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['bpm:form:update']"
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
@@ -137,16 +147,18 @@ const resetQuery = () => {
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const openForm = (id?: number) => {
|
||||
const toRouter: { name: string; query?: { id: number } } = {
|
||||
name: 'BpmFormEditor'
|
||||
}
|
||||
// 表单新建的时候id传的是event需要排除
|
||||
if (typeof id === 'number') {
|
||||
toRouter.query = {
|
||||
id
|
||||
const openForm = (type: string, id?: number) => {
|
||||
const toRouter: { name: string; query: { type: string; id?: number } } = {
|
||||
name: 'BpmFormEditor',
|
||||
query: {
|
||||
type
|
||||
}
|
||||
}
|
||||
console.log(typeof id)
|
||||
// 表单新建的时候id传的是event需要排除
|
||||
if (typeof id === 'number' || typeof id === 'string') {
|
||||
toRouter.query.id = id
|
||||
}
|
||||
push(toRouter)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="formData.description" placeholder="请输入描述" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="成员" prop="memberUserIds">
|
||||
<el-select v-model="formData.memberUserIds" multiple placeholder="请选择成员">
|
||||
<el-form-item label="成员" prop="userIds">
|
||||
<el-select v-model="formData.userIds" multiple placeholder="请选择成员">
|
||||
<el-option
|
||||
v-for="user in userList"
|
||||
:key="user.id"
|
||||
@@ -28,7 +28,7 @@
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
@@ -60,13 +60,13 @@ const formData = ref({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
memberUserIds: undefined,
|
||||
userIds: undefined,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
})
|
||||
const formRules = reactive({
|
||||
name: [{ required: true, message: '组名不能为空', trigger: 'blur' }],
|
||||
description: [{ required: true, message: '描述不能为空', trigger: 'blur' }],
|
||||
memberUserIds: [{ required: true, message: '成员不能为空', trigger: 'blur' }],
|
||||
userIds: [{ required: true, message: '成员不能为空', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
@@ -124,7 +124,7 @@ const resetForm = () => {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
memberUserIds: undefined,
|
||||
userIds: undefined,
|
||||
status: CommonStatusEnum.ENABLE
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<template>
|
||||
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
@@ -61,7 +63,7 @@
|
||||
<el-table-column label="描述" align="center" prop="description" />
|
||||
<el-table-column label="成员" align="center">
|
||||
<template #default="scope">
|
||||
<span v-for="userId in scope.row.memberUserIds" :key="userId" class="pr-5px">
|
||||
<span v-for="userId in scope.row.userIds" :key="userId" class="pr-5px">
|
||||
{{ userList.find((user) => user.id === userId)?.nickname }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
663
src/views/bpm/model/CategoryDraggableModel.vue
Normal file
663
src/views/bpm/model/CategoryDraggableModel.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<template>
|
||||
<div class="flex items-center h-50px" v-memo="[categoryInfo.name, isCategorySorting]">
|
||||
<!-- 头部:分类名 -->
|
||||
<div class="flex items-center">
|
||||
<el-tooltip content="拖动排序" v-if="isCategorySorting">
|
||||
<Icon
|
||||
:size="22"
|
||||
icon="ic:round-drag-indicator"
|
||||
class="ml-10px category-drag-icon cursor-move text-#8a909c"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<h3 class="ml-20px mr-8px text-18px">{{ categoryInfo.name }}</h3>
|
||||
<div class="color-gray-600 text-16px"> ({{ categoryInfo.modelList?.length || 0 }}) </div>
|
||||
</div>
|
||||
<!-- 头部:操作 -->
|
||||
<div class="flex-1 flex" v-show="!isCategorySorting">
|
||||
<div
|
||||
v-if="categoryInfo.modelList.length > 0"
|
||||
class="ml-20px flex items-center"
|
||||
:class="[
|
||||
'transition-transform duration-300 cursor-pointer',
|
||||
isExpand ? 'rotate-180' : 'rotate-0'
|
||||
]"
|
||||
@click="isExpand = !isExpand"
|
||||
>
|
||||
<Icon icon="ep:arrow-down-bold" color="#999" />
|
||||
</div>
|
||||
<div class="ml-auto flex items-center" :class="isModelSorting ? 'mr-15px' : 'mr-45px'">
|
||||
<template v-if="!isModelSorting">
|
||||
<el-button
|
||||
v-if="categoryInfo.modelList.length > 0"
|
||||
link
|
||||
type="info"
|
||||
class="mr-20px"
|
||||
@click.stop="handleModelSort"
|
||||
>
|
||||
<Icon icon="fa:sort-amount-desc" class="mr-5px" />
|
||||
排序
|
||||
</el-button>
|
||||
<el-button v-else link type="info" class="mr-20px" @click.stop="openModelForm('create')">
|
||||
<Icon icon="fa:plus" class="mr-5px" />
|
||||
新建
|
||||
</el-button>
|
||||
<el-dropdown
|
||||
@command="(command) => handleCategoryCommand(command, categoryInfo)"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-button link type="info">
|
||||
<Icon icon="ep:setting" class="mr-5px" />
|
||||
分类
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="handleRename"> 重命名 </el-dropdown-item>
|
||||
<el-dropdown-item command="handleDeleteCategory"> 删除该类 </el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button @click.stop="handleModelSortCancel"> 取 消 </el-button>
|
||||
<el-button type="primary" @click.stop="handleModelSortSubmit"> 保存排序 </el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<el-collapse-transition>
|
||||
<div v-show="isExpand">
|
||||
<el-table
|
||||
v-if="modelList && modelList.length > 0"
|
||||
:class="categoryInfo.name"
|
||||
ref="tableRef"
|
||||
:data="modelList"
|
||||
row-key="id"
|
||||
:header-cell-style="tableHeaderStyle"
|
||||
:cell-style="tableCellStyle"
|
||||
:row-style="{ height: '68px' }"
|
||||
>
|
||||
<el-table-column label="流程名" prop="name" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center">
|
||||
<el-tooltip content="拖动排序" v-if="isModelSorting">
|
||||
<Icon
|
||||
icon="ic:round-drag-indicator"
|
||||
class="drag-icon cursor-move text-#8a909c mr-10px"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-image v-if="row.icon" :src="row.icon" class="h-38px w-38px mr-10px rounded" />
|
||||
<div v-else class="flow-icon">
|
||||
<span style="font-size: 12px; color: #fff">{{ subString(row.name, 0, 2) }}</span>
|
||||
</div>
|
||||
{{ row.name }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可见范围" prop="startUserIds" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-text v-if="!row.startUsers?.length && !row.startDepts?.length"> 全部可见 </el-text>
|
||||
<el-text v-else-if="row.startUsers.length === 1">
|
||||
{{ row.startUsers[0].nickname }}
|
||||
</el-text>
|
||||
<el-text v-else-if="row.startDepts?.length === 1">
|
||||
{{ row.startDepts[0].name }}
|
||||
</el-text>
|
||||
<el-text v-else-if="row.startDepts?.length > 1">
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="row.startDepts.map((dept: any) => dept.name).join('、')"
|
||||
>
|
||||
{{ row.startDepts[0].name }}等 {{ row.startDepts.length }} 个部门可见
|
||||
</el-tooltip>
|
||||
</el-text>
|
||||
<el-text v-else>
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="row.startUsers.map((user: any) => user.nickname).join('、')"
|
||||
>
|
||||
{{ row.startUsers[0].nickname }}等 {{ row.startUsers.length }} 人可见
|
||||
</el-tooltip>
|
||||
</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流程类型" prop="type" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :value="row.type" :type="DICT_TYPE.BPM_MODEL_TYPE" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="表单信息" prop="formType" min-width="150">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.formType === BpmModelFormType.NORMAL"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleFormDetail(scope.row)"
|
||||
>
|
||||
<span>{{ scope.row.formName }}</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="scope.row.formType === BpmModelFormType.CUSTOM"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleFormDetail(scope.row)"
|
||||
>
|
||||
<span>{{ scope.row.formCustomCreatePath }}</span>
|
||||
</el-button>
|
||||
<label v-else>暂无表单</label>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后发布" prop="deploymentTime" min-width="250">
|
||||
<template #default="scope">
|
||||
<div class="flex items-center">
|
||||
<span v-if="scope.row.processDefinition" class="w-150px">
|
||||
{{ formatDate(scope.row.processDefinition.deploymentTime) }}
|
||||
</span>
|
||||
<el-tag v-if="scope.row.processDefinition">
|
||||
v{{ scope.row.processDefinition.version }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="warning">未部署</el-tag>
|
||||
<el-tag
|
||||
v-if="scope.row.processDefinition?.suspensionState === 2"
|
||||
type="warning"
|
||||
class="ml-10px"
|
||||
>
|
||||
已停用
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openModelForm('update', scope.row.id)"
|
||||
v-if="hasPermiUpdate"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openModelForm('copy', scope.row.id)"
|
||||
v-if="hasPermiUpdate"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
class="!ml-5px"
|
||||
type="primary"
|
||||
@click="handleDeploy(scope.row)"
|
||||
v-if="hasPermiDeploy"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-dropdown
|
||||
class="!align-middle ml-5px"
|
||||
@command="(command) => handleModelCommand(command, scope.row)"
|
||||
v-if="hasPermiMore"
|
||||
>
|
||||
<el-button type="primary" link>更多</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="handleDefinitionList" v-if="hasPermiPdQuery">
|
||||
历史
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="handleReport"
|
||||
v-if="
|
||||
checkPermi(['bpm:process-instance:manager-query']) &&
|
||||
scope.row.processDefinition
|
||||
"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
报表
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="handleChangeState"
|
||||
v-if="hasPermiUpdate && scope.row.processDefinition"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
{{ scope.row.processDefinition.suspensionState === 1 ? '停用' : '启用' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
type="danger"
|
||||
command="handleClean"
|
||||
v-if="checkPermi(['bpm:model:clean'])"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
清理
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
type="danger"
|
||||
command="handleDelete"
|
||||
v-if="hasPermiDelete"
|
||||
:disabled="!isManagerUser(scope.row)"
|
||||
>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
|
||||
<!-- 弹窗:重命名分类 -->
|
||||
<Dialog :fullscreen="false" class="rename-dialog" v-model="renameCategoryVisible" width="400">
|
||||
<template #title>
|
||||
<div class="pl-10px font-bold text-18px"> 重命名分类 </div>
|
||||
</template>
|
||||
<div class="px-30px">
|
||||
<el-input v-model="renameCategoryForm.name" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="pr-25px pb-25px">
|
||||
<el-button @click="renameCategoryVisible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleRenameConfirm">确 定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 弹窗:表单详情 -->
|
||||
<Dialog title="表单详情" :fullscreen="true" v-model="formDetailVisible">
|
||||
<form-create :rule="formDetailPreview.rule" :option="formDetailPreview.option" />
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
||||
import Sortable from 'sortablejs'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import * as ModelApi from '@/api/bpm/model'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
import { setConfAndFields2 } from '@/utils/formCreate'
|
||||
import { BpmModelFormType } from '@/utils/constants'
|
||||
import { checkPermi } from '@/utils/permission'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { cloneDeep, isEqual } from 'lodash-es'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { subString } from '@/utils/index'
|
||||
|
||||
defineOptions({ name: 'BpmModel' })
|
||||
|
||||
// 优化 Props 类型定义
|
||||
interface UserInfo {
|
||||
nickname: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface ProcessDefinition {
|
||||
deploymentTime: string
|
||||
version: number
|
||||
suspensionState: number
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string
|
||||
startUsers?: UserInfo[]
|
||||
processDefinition?: ProcessDefinition
|
||||
formType?: number
|
||||
formId?: number
|
||||
formName?: string
|
||||
formCustomCreatePath?: string
|
||||
managerUserIds?: number[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface CategoryInfoProps {
|
||||
id: number
|
||||
name: string
|
||||
modelList: ModelInfo[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
categoryInfo: CategoryInfoProps
|
||||
isCategorySorting: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
const { push } = useRouter() // 路由
|
||||
const userStore = useUserStoreWithOut() // 用户信息缓存
|
||||
const isDark = computed(() => useAppStore().getIsDark) // 是否黑暗模式
|
||||
const router = useRouter() // 路由
|
||||
|
||||
const isModelSorting = ref(false) // 是否正处于排序状态
|
||||
const originalData = ref<ModelInfo[]>([]) // 原始数据
|
||||
const modelList = ref<ModelInfo[]>([]) // 模型列表
|
||||
const isExpand = ref(false) // 是否处于展开状态
|
||||
|
||||
// 使用 computed 优化表格样式计算
|
||||
const tableHeaderStyle = computed(() => ({
|
||||
backgroundColor: isDark.value ? '' : '#edeff0',
|
||||
paddingLeft: '10px'
|
||||
}))
|
||||
|
||||
const tableCellStyle = computed(() => ({
|
||||
paddingLeft: '10px'
|
||||
}))
|
||||
|
||||
/** 权限校验:通过 computed 解决列表的卡顿问题 */
|
||||
const hasPermiUpdate = computed(() => {
|
||||
return checkPermi(['bpm:model:update'])
|
||||
})
|
||||
const hasPermiDelete = computed(() => {
|
||||
return checkPermi(['bpm:model:delete'])
|
||||
})
|
||||
const hasPermiDeploy = computed(() => {
|
||||
return checkPermi(['bpm:model:deploy'])
|
||||
})
|
||||
const hasPermiMore = computed(() => {
|
||||
return checkPermi(['bpm:process-definition:query', 'bpm:model:update', 'bpm:model:delete'])
|
||||
})
|
||||
const hasPermiPdQuery = computed(() => {
|
||||
return checkPermi(['bpm:process-definition:query'])
|
||||
})
|
||||
|
||||
/** '更多'操作按钮 */
|
||||
const handleModelCommand = (command: string, row: any) => {
|
||||
switch (command) {
|
||||
case 'handleDefinitionList':
|
||||
handleDefinitionList(row)
|
||||
break
|
||||
case 'handleDelete':
|
||||
handleDelete(row)
|
||||
break
|
||||
case 'handleChangeState':
|
||||
handleChangeState(row)
|
||||
break
|
||||
case 'handleClean':
|
||||
handleClean(row)
|
||||
break
|
||||
case 'handleReport':
|
||||
router.push({
|
||||
name: 'BpmProcessInstanceReport',
|
||||
query: {
|
||||
processDefinitionId: row.processDefinition.id,
|
||||
processDefinitionKey: row.key
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** '分类'操作按钮 */
|
||||
const handleCategoryCommand = async (command: string, row: any) => {
|
||||
switch (command) {
|
||||
case 'handleRename':
|
||||
renameCategoryForm.value = await CategoryApi.getCategory(row.id)
|
||||
renameCategoryVisible.value = true
|
||||
break
|
||||
case 'handleDeleteCategory':
|
||||
await handleDeleteCategory()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ModelApi.deleteModel(row.id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
emit('success')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 清理按钮操作 */
|
||||
const handleClean = async (row: any) => {
|
||||
try {
|
||||
// 清理的二次确认
|
||||
await message.confirm('是否确认清理流程名字为"' + row.name + '"的数据项?')
|
||||
// 发起清理
|
||||
await ModelApi.cleanModel(row.id)
|
||||
message.success('清理成功')
|
||||
// 刷新列表
|
||||
emit('success')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 更新状态操作 */
|
||||
const handleChangeState = async (row: any) => {
|
||||
const state = row.processDefinition.suspensionState
|
||||
const newState = state === 1 ? 2 : 1
|
||||
try {
|
||||
// 修改状态的二次确认
|
||||
const id = row.id
|
||||
const statusState = state === 1 ? '停用' : '启用'
|
||||
const content = '是否确认' + statusState + '流程名字为"' + row.name + '"的数据项?'
|
||||
await message.confirm(content)
|
||||
// 发起修改状态
|
||||
await ModelApi.updateModelState(id, newState)
|
||||
message.success(statusState + '成功')
|
||||
// 刷新列表
|
||||
emit('success')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 发布流程 */
|
||||
const handleDeploy = async (row: any) => {
|
||||
try {
|
||||
await message.confirm('是否确认发布该流程?')
|
||||
// 发起部署
|
||||
await ModelApi.deployModel(row.id)
|
||||
message.success(t('发布成功'))
|
||||
// 刷新列表
|
||||
emit('success')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 跳转到指定流程定义列表 */
|
||||
const handleDefinitionList = (row: any) => {
|
||||
push({
|
||||
name: 'BpmProcessDefinition',
|
||||
query: {
|
||||
key: row.key
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 流程表单的详情按钮操作 */
|
||||
const formDetailVisible = ref(false)
|
||||
const formDetailPreview = ref({
|
||||
rule: [],
|
||||
option: {}
|
||||
})
|
||||
const handleFormDetail = async (row: any) => {
|
||||
if (row.formType == BpmModelFormType.NORMAL) {
|
||||
// 设置表单
|
||||
const data = await FormApi.getForm(row.formId)
|
||||
setConfAndFields2(formDetailPreview, data.conf, data.fields)
|
||||
// 弹窗打开
|
||||
formDetailVisible.value = true
|
||||
} else {
|
||||
await push({
|
||||
path: row.formCustomCreatePath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断是否可以操作 */
|
||||
const isManagerUser = (row: any) => {
|
||||
const userId = userStore.getUser.id
|
||||
return row.managerUserIds && row.managerUserIds.includes(userId)
|
||||
}
|
||||
|
||||
/** 处理模型的排序 **/
|
||||
const handleModelSort = () => {
|
||||
// 保存初始数据
|
||||
originalData.value = cloneDeep(props.categoryInfo.modelList)
|
||||
isModelSorting.value = true
|
||||
initSort()
|
||||
}
|
||||
|
||||
/** 处理模型的排序提交 */
|
||||
const handleModelSortSubmit = async () => {
|
||||
// 保存排序
|
||||
const ids = modelList.value.map((item: any) => item.id)
|
||||
await ModelApi.updateModelSortBatch(ids)
|
||||
// 刷新列表
|
||||
isModelSorting.value = false
|
||||
message.success('排序模型成功')
|
||||
emit('success')
|
||||
}
|
||||
|
||||
/** 处理模型的排序取消 */
|
||||
const handleModelSortCancel = () => {
|
||||
// 恢复初始数据
|
||||
modelList.value = cloneDeep(originalData.value)
|
||||
isModelSorting.value = false
|
||||
}
|
||||
|
||||
/** 创建拖拽实例 */
|
||||
const tableRef = ref()
|
||||
const initSort = useDebounceFn(() => {
|
||||
const table = document.querySelector(`.${props.categoryInfo.name} .el-table__body-wrapper tbody`)
|
||||
if (!table) return
|
||||
|
||||
Sortable.create(table, {
|
||||
group: 'shared',
|
||||
animation: 150,
|
||||
draggable: '.el-table__row',
|
||||
handle: '.drag-icon',
|
||||
onEnd: ({ newDraggableIndex, oldDraggableIndex }) => {
|
||||
if (oldDraggableIndex !== newDraggableIndex) {
|
||||
modelList.value.splice(
|
||||
newDraggableIndex,
|
||||
0,
|
||||
modelList.value.splice(oldDraggableIndex, 1)[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 200)
|
||||
|
||||
/** 更新 modelList 模型列表 */
|
||||
const updateModeList = useDebounceFn(() => {
|
||||
const newModelList = props.categoryInfo.modelList
|
||||
if (!isEqual(modelList.value, newModelList)) {
|
||||
modelList.value = cloneDeep(newModelList)
|
||||
if (newModelList?.length > 0) {
|
||||
isExpand.value = true
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
/** 重命名弹窗确定 */
|
||||
const renameCategoryVisible = ref(false)
|
||||
const renameCategoryForm = ref({
|
||||
name: ''
|
||||
})
|
||||
const handleRenameConfirm = async () => {
|
||||
if (renameCategoryForm.value?.name.length === 0) {
|
||||
return message.warning('请输入名称')
|
||||
}
|
||||
// 发起修改
|
||||
await CategoryApi.updateCategory(renameCategoryForm.value as CategoryVO)
|
||||
message.success('重命名成功')
|
||||
// 刷新列表
|
||||
renameCategoryVisible.value = false
|
||||
emit('success')
|
||||
}
|
||||
|
||||
/** 删除分类 */
|
||||
const handleDeleteCategory = async () => {
|
||||
try {
|
||||
if (props.categoryInfo.modelList.length > 0) {
|
||||
return message.warning('该分类下仍有流程定义,不允许删除')
|
||||
}
|
||||
await message.confirm('确认删除分类吗?')
|
||||
// 发起删除
|
||||
await CategoryApi.deleteCategory(props.categoryInfo.id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
emit('success')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 添加/修改/复制流程模型弹窗 */
|
||||
const openModelForm = async (type: string, id?: number) => {
|
||||
if (type === 'create') {
|
||||
await push({ name: 'BpmModelCreate' })
|
||||
} else {
|
||||
await push({
|
||||
name: 'BpmModelUpdate',
|
||||
params: { id, type }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.categoryInfo?.modelList) {
|
||||
updateModeList()
|
||||
}
|
||||
|
||||
if (props.isCategorySorting) {
|
||||
isExpand.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.rename-dialog.el-dialog {
|
||||
padding: 0 !important;
|
||||
|
||||
.el-dialog__header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
.flow-icon {
|
||||
display: flex;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-right: 10px;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 0.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-draggable-model {
|
||||
:deep(.el-table__cell) {
|
||||
overflow: hidden;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
// 优化表格渲染性能
|
||||
:deep(.el-table__body) {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,230 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model="dialogVisible" :title="dialogTitle" width="600">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
v-loading="formLoading"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="110px"
|
||||
>
|
||||
<el-form-item label="流程标识" prop="key">
|
||||
<el-input
|
||||
v-model="formData.key"
|
||||
:disabled="!!formData.id"
|
||||
placeholder="请输入流标标识"
|
||||
style="width: 330px"
|
||||
/>
|
||||
<el-tooltip
|
||||
v-if="!formData.id"
|
||||
class="item"
|
||||
content="新建后,流程标识不可修改!"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<i class="el-icon-question" style="padding-left: 5px"></i>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-else class="item" content="流程标识不可修改!" effect="light" placement="top">
|
||||
<i class="el-icon-question" style="padding-left: 5px"></i>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input
|
||||
v-model="formData.name"
|
||||
:disabled="!!formData.id"
|
||||
clearable
|
||||
placeholder="请输入流程名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.id" label="流程分类" prop="category">
|
||||
<el-select
|
||||
v-model="formData.category"
|
||||
clearable
|
||||
placeholder="请选择流程分类"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_CATEGORY)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程描述" prop="description">
|
||||
<el-input v-model="formData.description" clearable type="textarea" />
|
||||
</el-form-item>
|
||||
<div v-if="formData.id">
|
||||
<el-form-item label="表单类型" prop="formType">
|
||||
<el-radio-group v-model="formData.formType">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_FORM_TYPE)"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.formType === 10" label="流程表单" prop="formId">
|
||||
<el-select v-model="formData.formId" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="form in formList"
|
||||
:key="form.id"
|
||||
:label="form.name"
|
||||
:value="form.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.formType === 20"
|
||||
label="表单提交路由"
|
||||
prop="formCustomCreatePath"
|
||||
>
|
||||
<el-input
|
||||
v-model="formData.formCustomCreatePath"
|
||||
placeholder="请输入表单提交路由"
|
||||
style="width: 330px"
|
||||
/>
|
||||
<el-tooltip
|
||||
class="item"
|
||||
content="自定义表单的提交路径,使用 Vue 的路由地址,例如说:bpm/oa/leave/create"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<i class="el-icon-question" style="padding-left: 5px"></i>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formData.formType === 20"
|
||||
label="表单查看路由"
|
||||
prop="formCustomViewPath"
|
||||
>
|
||||
<el-input
|
||||
v-model="formData.formCustomViewPath"
|
||||
placeholder="请输入表单查看路由"
|
||||
style="width: 330px"
|
||||
/>
|
||||
<el-tooltip
|
||||
class="item"
|
||||
content="自定义表单的查看路径,使用 Vue 的路由地址,例如说:bpm/oa/leave/view"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<i class="el-icon-question" style="padding-left: 5px"></i>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import * as ModelApi from '@/api/bpm/model'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
|
||||
defineOptions({ name: 'ModelForm' })
|
||||
|
||||
const { t } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const dialogTitle = ref('') // 弹窗的标题
|
||||
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||
const formType = ref('') // 表单的类型:create - 新增;update - 修改
|
||||
const formData = ref({
|
||||
formType: 10,
|
||||
name: '',
|
||||
category: undefined,
|
||||
description: '',
|
||||
formId: '',
|
||||
formCustomCreatePath: '',
|
||||
formCustomViewPath: ''
|
||||
})
|
||||
const formRules = reactive({
|
||||
category: [{ required: true, message: '参数分类不能为空', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '参数名称不能为空', trigger: 'blur' }],
|
||||
key: [{ required: true, message: '参数键名不能为空', trigger: 'blur' }],
|
||||
value: [{ required: true, message: '参数键值不能为空', trigger: 'blur' }],
|
||||
visible: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const formList = ref([]) // 流程表单的下拉框的数据
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async (type: string, id?: number) => {
|
||||
dialogVisible.value = true
|
||||
dialogTitle.value = t('action.' + type)
|
||||
formType.value = type
|
||||
resetForm()
|
||||
// 修改时,设置数据
|
||||
if (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
formData.value = await ModelApi.getModel(id)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
// 获得流程表单的下拉框的数据
|
||||
formList.value = await FormApi.getSimpleFormList()
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
if (!formRef) return
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
// 提交请求
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown as ModelApi.ModelVO
|
||||
if (formType.value === 'create') {
|
||||
await ModelApi.createModel(data)
|
||||
// 提示,引导用户做后续的操作
|
||||
await ElMessageBox.alert(
|
||||
'<strong>新建模型成功!</strong>后续需要执行如下 4 个步骤:' +
|
||||
'<div>1. 点击【修改流程】按钮,配置流程的分类、表单信息</div>' +
|
||||
'<div>2. 点击【设计流程】按钮,绘制流程图</div>' +
|
||||
'<div>3. 点击【分配规则】按钮,设置每个用户任务的审批人</div>' +
|
||||
'<div>4. 点击【发布流程】按钮,完成流程的最终发布</div>' +
|
||||
'另外,每次流程修改后,都需要点击【发布流程】按钮,才能正式生效!!!',
|
||||
'重要提示',
|
||||
{
|
||||
dangerouslyUseHTMLString: true,
|
||||
type: 'success'
|
||||
}
|
||||
)
|
||||
} else {
|
||||
await ModelApi.updateModel(data)
|
||||
message.success(t('common.updateSuccess'))
|
||||
}
|
||||
dialogVisible.value = false
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
formType: 10,
|
||||
name: '',
|
||||
category: undefined,
|
||||
description: '',
|
||||
formId: '',
|
||||
formCustomCreatePath: '',
|
||||
formCustomViewPath: ''
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model="dialogVisible" title="导入流程" width="400">
|
||||
<div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
v-model:file-list="fileList"
|
||||
:action="importUrl"
|
||||
:auto-upload="false"
|
||||
:data="formData"
|
||||
:disabled="formLoading"
|
||||
:headers="uploadHeaders"
|
||||
:limit="1"
|
||||
:on-error="submitFormError"
|
||||
:on-exceed="handleExceed"
|
||||
:on-success="submitFormSuccess"
|
||||
accept=".bpmn, .xml"
|
||||
drag
|
||||
name="bpmnFile"
|
||||
>
|
||||
<Icon class="el-icon--upload" icon="ep:upload-filled" />
|
||||
<div class="el-upload__text"> 将文件拖到此处,或 <em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip" style="color: red">
|
||||
提示:仅允许导入“bpm”或“xml”格式文件!
|
||||
</div>
|
||||
<div>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="流程标识" prop="key">
|
||||
<el-input
|
||||
v-model="formData.key"
|
||||
placeholder="请输入流标标识"
|
||||
style="width: 250px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input v-model="formData.name" clearable placeholder="请输入流程名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="流程描述" prop="description">
|
||||
<el-input v-model="formData.description" clearable type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { getAccessToken, getTenantId } from '@/utils/auth'
|
||||
|
||||
defineOptions({ name: 'ModelImportForm' })
|
||||
|
||||
const message = useMessage() // 消息弹窗
|
||||
|
||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||
const formLoading = ref(false) // 表单的加载中
|
||||
const formData = ref({
|
||||
key: '',
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
const formRules = reactive({
|
||||
key: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const formRef = ref() // 表单 Ref
|
||||
const uploadRef = ref() // 上传 Ref
|
||||
const importUrl = import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/bpm/model/import'
|
||||
const uploadHeaders = ref() // 上传 Header 头
|
||||
const fileList = ref([]) // 文件列表
|
||||
|
||||
/** 打开弹窗 */
|
||||
const open = async () => {
|
||||
dialogVisible.value = true
|
||||
resetForm()
|
||||
}
|
||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||
|
||||
/** 提交表单 */
|
||||
const submitForm = async () => {
|
||||
// 校验表单
|
||||
if (!formRef) return
|
||||
const valid = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
if (fileList.value.length == 0) {
|
||||
message.error('请上传文件')
|
||||
return
|
||||
}
|
||||
// 提交请求
|
||||
uploadHeaders.value = {
|
||||
Authorization: 'Bearer ' + getAccessToken(),
|
||||
'tenant-id': getTenantId()
|
||||
}
|
||||
formLoading.value = true
|
||||
uploadRef.value!.submit()
|
||||
}
|
||||
|
||||
/** 文件上传成功 */
|
||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||
const submitFormSuccess = async (response: any) => {
|
||||
if (response.code !== 0) {
|
||||
message.error(response.msg)
|
||||
formLoading.value = false
|
||||
return
|
||||
}
|
||||
// 提示成功
|
||||
message.success('导入流程成功!请点击【设计流程】按钮,进行编辑保存后,才可以进行【发布流程】')
|
||||
// 发送操作成功的事件
|
||||
emit('success')
|
||||
}
|
||||
|
||||
/** 上传错误提示 */
|
||||
const submitFormError = (): void => {
|
||||
message.error('导入流程失败,请您重新上传!')
|
||||
formLoading.value = false
|
||||
}
|
||||
|
||||
/** 重置表单 */
|
||||
const resetForm = () => {
|
||||
// 重置上传状态和文件
|
||||
formLoading.value = false
|
||||
uploadRef.value?.clearFiles()
|
||||
// 重置表单
|
||||
formData.value = {
|
||||
key: '',
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
/** 文件数超出提示 */
|
||||
const handleExceed = (): void => {
|
||||
message.error('最多只能上传一个文件!')
|
||||
}
|
||||
</script>
|
||||
@@ -1,44 +1,62 @@
|
||||
<template>
|
||||
<doc-alert title="工作流手册" url="https://doc.iocoder.cn/bpm/" />
|
||||
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="定义编号" align="center" prop="id" width="400" />
|
||||
<el-table-column label="流程名称" align="center" prop="name" width="200">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="handleBpmnDetail(scope.row)">
|
||||
<span>{{ scope.row.name }}</span>
|
||||
</el-button>
|
||||
<el-table-column label="定义编号" align="center" prop="id" min-width="250" />
|
||||
<el-table-column label="流程名称" align="center" prop="name" min-width="150" />
|
||||
<el-table-column label="流程图标" align="center" min-width="50">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.icon" :src="row.icon" class="h-24px w-24pxrounded" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="定义分类" align="center" prop="category" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.BPM_MODEL_CATEGORY" :value="scope.row.category" />
|
||||
<el-table-column label="可见范围" prop="startUserIds" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<el-text v-if="!row.startUsers?.length"> 全部可见 </el-text>
|
||||
<el-text v-else-if="row.startUsers.length === 1">
|
||||
{{ row.startUsers[0].nickname }}
|
||||
</el-text>
|
||||
<el-text v-else>
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
placement="top"
|
||||
:content="row.startUsers.map((user: any) => user.nickname).join('、')"
|
||||
>
|
||||
{{ row.startUsers[0].nickname }}等 {{ row.startUsers.length }} 人可见
|
||||
</el-tooltip>
|
||||
</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="表单信息" align="center" prop="formType" width="200">
|
||||
<el-table-column label="流程类型" prop="modelType" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :value="row.modelType" :type="DICT_TYPE.BPM_MODEL_TYPE" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="表单信息" prop="formType" min-width="150">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.formType === 10"
|
||||
v-if="scope.row.formType === BpmModelFormType.NORMAL"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleFormDetail(scope.row)"
|
||||
>
|
||||
<span>{{ scope.row.formName }}</span>
|
||||
</el-button>
|
||||
<el-button v-else type="primary" link @click="handleFormDetail(scope.row)">
|
||||
<el-button
|
||||
v-else-if="scope.row.formType === BpmModelFormType.CUSTOM"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleFormDetail(scope.row)"
|
||||
>
|
||||
<span>{{ scope.row.formCustomCreatePath }}</span>
|
||||
</el-button>
|
||||
<label v-else>暂无表单</label>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流程版本" align="center" prop="processDefinition.version" width="80">
|
||||
<el-table-column label="流程版本" align="center" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row">v{{ scope.row.version }}</el-tag>
|
||||
<el-tag type="warning" v-else>未部署</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" prop="version" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.suspensionState === 1">激活</el-tag>
|
||||
<el-tag type="warning" v-if="scope.row.suspensionState === 2">挂起</el-tag>
|
||||
<el-tag>v{{ scope.row.version }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
@@ -48,22 +66,15 @@
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column
|
||||
label="定义描述"
|
||||
align="center"
|
||||
prop="description"
|
||||
width="300"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" align="center" width="150" fixed="right">
|
||||
<el-table-column label="操作" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleAssignRule(scope.row)"
|
||||
v-hasPermi="['bpm:task-assign-rule:query']"
|
||||
@click="openModelForm(scope.row.id)"
|
||||
v-hasPermi="['bpm:model:update']"
|
||||
>
|
||||
分配规则
|
||||
恢复
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -81,25 +92,14 @@
|
||||
<Dialog title="表单详情" v-model="formDetailVisible" width="800">
|
||||
<form-create :rule="formDetailPreview.rule" :option="formDetailPreview.option" />
|
||||
</Dialog>
|
||||
|
||||
<!-- 弹窗:流程模型图的预览 -->
|
||||
<Dialog title="流程图" v-model="bpmnDetailVisible" width="800">
|
||||
<MyProcessViewer
|
||||
key="designer"
|
||||
v-model="bpmnXML"
|
||||
:value="bpmnXML as any"
|
||||
v-bind="bpmnControlForm"
|
||||
:prefix="bpmnControlForm.prefix"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
import { dateFormatter } from '@/utils/formatTime'
|
||||
import { MyProcessViewer } from '@/components/bpmnProcessDesigner/package'
|
||||
import * as DefinitionApi from '@/api/bpm/definition'
|
||||
import { setConfAndFields2 } from '@/utils/formCreate'
|
||||
import { DICT_TYPE } from '@/utils/dict'
|
||||
import { BpmModelFormType } from '@/utils/constants'
|
||||
|
||||
defineOptions({ name: 'BpmProcessDefinition' })
|
||||
|
||||
@@ -127,24 +127,14 @@ const getList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击任务分配按钮 */
|
||||
const handleAssignRule = (row) => {
|
||||
push({
|
||||
name: 'BpmTaskAssignRuleList',
|
||||
query: {
|
||||
modelId: row.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 流程表单的详情按钮操作 */
|
||||
const formDetailVisible = ref(false)
|
||||
const formDetailPreview = ref({
|
||||
rule: [],
|
||||
option: {}
|
||||
})
|
||||
const handleFormDetail = async (row) => {
|
||||
if (row.formType == 10) {
|
||||
const handleFormDetail = async (row: any) => {
|
||||
if (row.formType == BpmModelFormType.NORMAL) {
|
||||
// 设置表单
|
||||
setConfAndFields2(formDetailPreview, row.formConf, row.formFields)
|
||||
// 弹窗打开
|
||||
@@ -156,15 +146,12 @@ const handleFormDetail = async (row) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 流程图的详情按钮操作 */
|
||||
const bpmnDetailVisible = ref(false)
|
||||
const bpmnXML = ref(null)
|
||||
const bpmnControlForm = ref({
|
||||
prefix: 'flowable'
|
||||
})
|
||||
const handleBpmnDetail = async (row) => {
|
||||
bpmnXML.value = await DefinitionApi.getProcessDefinitionBpmnXML(row.id)
|
||||
bpmnDetailVisible.value = true
|
||||
/** 恢复流程模型弹窗 */
|
||||
const openModelForm = async (id?: number) => {
|
||||
await push({
|
||||
name: 'BpmModelUpdate',
|
||||
params: { id, type: 'definition' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
@@ -172,3 +159,16 @@ onMounted(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flow-icon {
|
||||
display: flex;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-right: 10px;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 0.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 流程设计器,负责绘制流程等 -->
|
||||
<MyProcessDesigner
|
||||
key="designer"
|
||||
v-if="xmlString !== undefined"
|
||||
v-model="xmlString"
|
||||
:value="xmlString"
|
||||
v-bind="controlForm"
|
||||
keyboard
|
||||
ref="processDesigner"
|
||||
@init-finished="initModeler"
|
||||
:additionalModel="controlForm.additionalModel"
|
||||
@save="save"
|
||||
/>
|
||||
<!-- 流程属性器,负责编辑每个流程节点的属性 -->
|
||||
<MyProcessPenal
|
||||
key="penal"
|
||||
:bpmnModeler="modeler as any"
|
||||
:prefix="controlForm.prefix"
|
||||
class="process-panel"
|
||||
:model="model"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MyProcessDesigner, MyProcessPenal } from '@/components/bpmnProcessDesigner/package'
|
||||
// 自定义元素选中时的弹出菜单(修改 默认任务 为 用户任务)
|
||||
import CustomContentPadProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/content-pad'
|
||||
// 自定义左侧菜单(修改 默认任务 为 用户任务)
|
||||
import CustomPaletteProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/palette'
|
||||
import * as ModelApi from '@/api/bpm/model'
|
||||
|
||||
defineOptions({ name: 'BpmModelEditor' })
|
||||
|
||||
const router = useRouter() // 路由
|
||||
const { query } = useRoute() // 路由的查询
|
||||
const message = useMessage() // 国际化
|
||||
|
||||
const xmlString = ref(undefined) // BPMN XML
|
||||
const modeler = ref(null) // BPMN Modeler
|
||||
const controlForm = ref({
|
||||
simulation: true,
|
||||
labelEditing: false,
|
||||
labelVisible: false,
|
||||
prefix: 'flowable',
|
||||
headerButtonSize: 'mini',
|
||||
additionalModel: [CustomContentPadProvider, CustomPaletteProvider]
|
||||
})
|
||||
const model = ref<ModelApi.ModelVO>() // 流程模型的信息
|
||||
|
||||
/** 初始化 modeler */
|
||||
const initModeler = (item) => {
|
||||
setTimeout(() => {
|
||||
modeler.value = item
|
||||
}, 10)
|
||||
}
|
||||
|
||||
/** 添加/修改模型 */
|
||||
const save = async (bpmnXml) => {
|
||||
const data = {
|
||||
...model.value,
|
||||
bpmnXml: bpmnXml // bpmnXml 只是初始化流程图,后续修改无法通过它获得
|
||||
} as unknown as ModelApi.ModelVO
|
||||
// 提交
|
||||
if (data.id) {
|
||||
await ModelApi.updateModel(data)
|
||||
message.success('修改成功')
|
||||
} else {
|
||||
await ModelApi.createModel(data)
|
||||
message.success('新增成功')
|
||||
}
|
||||
// 跳转回去
|
||||
close()
|
||||
}
|
||||
|
||||
/** 关闭按钮 */
|
||||
const close = () => {
|
||||
router.push({ path: '/bpm/manager/model' })
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
const modelId = query.modelId as unknown as number
|
||||
if (!modelId) {
|
||||
message.error('缺少模型 modelId 编号')
|
||||
return
|
||||
}
|
||||
// 查询模型
|
||||
const data = await ModelApi.getModel(modelId)
|
||||
xmlString.value = data.bpmnXml
|
||||
model.value = {
|
||||
...data,
|
||||
bpmnXml: undefined // 清空 bpmnXml 属性
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.process-panel__container {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
right: 60px;
|
||||
}
|
||||
</style>
|
||||
344
src/views/bpm/model/form/BasicInfo.vue
Normal file
344
src/views/bpm/model/form/BasicInfo.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :model="modelData" :rules="rules" label-width="120px" class="mt-20px">
|
||||
<el-form-item label="流程标识" prop="key" class="mb-20px">
|
||||
<div class="flex items-center">
|
||||
<el-input
|
||||
class="!w-440px"
|
||||
v-model="modelData.key"
|
||||
:disabled="!!modelData.id"
|
||||
placeholder="请输入流程标识,以字母或下划线开头"
|
||||
/>
|
||||
<el-tooltip
|
||||
class="item"
|
||||
:content="modelData.id ? '流程标识不可修改!' : '新建后,流程标识不可修改!'"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<Icon icon="ep:question-filled" class="ml-5px" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程名称" prop="name" class="mb-20px">
|
||||
<el-input
|
||||
v-model="modelData.name"
|
||||
:disabled="!!modelData.id"
|
||||
clearable
|
||||
placeholder="请输入流程名称"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程分类" prop="category" class="mb-20px">
|
||||
<el-select
|
||||
class="!w-full"
|
||||
v-model="modelData.category"
|
||||
clearable
|
||||
placeholder="请选择流程分类"
|
||||
>
|
||||
<el-option
|
||||
v-for="category in categoryList"
|
||||
:key="category.code"
|
||||
:label="category.name"
|
||||
:value="category.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程图标" class="mb-20px">
|
||||
<UploadImg v-model="modelData.icon" :limit="1" height="64px" width="64px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="流程描述" prop="description" class="mb-20px">
|
||||
<el-input v-model="modelData.description" clearable type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="流程类型" prop="type" class="mb-20px">
|
||||
<el-radio-group v-model="modelData.type">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否可见" prop="visible" class="mb-20px">
|
||||
<el-radio-group v-model="modelData.visible">
|
||||
<el-radio
|
||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||
:key="dict.value as string"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="谁可以发起" prop="startUserType" class="mb-20px">
|
||||
<el-select
|
||||
v-model="modelData.startUserType"
|
||||
placeholder="请选择谁可以发起"
|
||||
@change="handleStartUserTypeChange"
|
||||
>
|
||||
<el-option label="全员" :value="0" />
|
||||
<el-option label="指定人员" :value="1" />
|
||||
<el-option label="指定部门" :value="2" />
|
||||
</el-select>
|
||||
<div v-if="modelData.startUserType === 1" class="mt-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="user in selectedStartUsers"
|
||||
:key="user.id"
|
||||
class="bg-gray-100 h-35px rounded-3xl flex items-center pr-8px dark:color-gray-600 position-relative"
|
||||
>
|
||||
<el-avatar class="!m-5px" :size="28" v-if="user.avatar" :src="user.avatar" />
|
||||
<el-avatar class="!m-5px" :size="28" v-else>
|
||||
{{ user.nickname.substring(0, 1) }}
|
||||
</el-avatar>
|
||||
{{ user.nickname }}
|
||||
<Icon
|
||||
icon="ep:close"
|
||||
class="ml-2 cursor-pointer hover:text-red-500"
|
||||
@click="handleRemoveStartUser(user)"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" link @click="openStartUserSelect">
|
||||
<Icon icon="ep:plus" /> 选择人员
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="modelData.startUserType === 2" class="mt-2 flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="dept in selectedStartDepts"
|
||||
:key="dept.id"
|
||||
class="bg-gray-100 h-35px rounded-3xl flex items-center pr-8px dark:color-gray-600 position-relative"
|
||||
>
|
||||
<Icon icon="ep:office-building" class="!m-5px text-20px" />
|
||||
{{ dept.name }}
|
||||
<Icon
|
||||
icon="ep:close"
|
||||
class="ml-2 cursor-pointer hover:text-red-500"
|
||||
@click="handleRemoveStartDept(dept)"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" link @click="openStartDeptSelect">
|
||||
<Icon icon="ep:plus" /> 选择部门
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程管理员" prop="managerUserIds" class="mb-20px">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="user in selectedManagerUsers"
|
||||
:key="user.id"
|
||||
class="bg-gray-100 h-35px rounded-3xl flex items-center pr-8px dark:color-gray-600 position-relative"
|
||||
>
|
||||
<el-avatar class="!m-5px" :size="28" v-if="user.avatar" :src="user.avatar" />
|
||||
<el-avatar class="!m-5px" :size="28" v-else>
|
||||
{{ user.nickname.substring(0, 1) }}
|
||||
</el-avatar>
|
||||
{{ user.nickname }}
|
||||
<Icon
|
||||
icon="ep:close"
|
||||
class="ml-2 cursor-pointer hover:text-red-500"
|
||||
@click="handleRemoveManagerUser(user)"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" link @click="openManagerUserSelect">
|
||||
<Icon icon="ep:plus" />选择人员
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 用户选择弹窗 -->
|
||||
<UserSelectForm ref="userSelectFormRef" @confirm="handleUserSelectConfirm" />
|
||||
<!-- 部门选择弹窗 -->
|
||||
<DeptSelectForm
|
||||
ref="deptSelectFormRef"
|
||||
:multiple="true"
|
||||
:check-strictly="true"
|
||||
@confirm="handleDeptSelectConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict'
|
||||
import { UserVO } from '@/api/system/user'
|
||||
import { DeptVO } from '@/api/system/dept'
|
||||
import { CategoryVO } from '@/api/bpm/category'
|
||||
|
||||
const props = defineProps({
|
||||
categoryList: {
|
||||
type: Array as PropType<CategoryVO[]>,
|
||||
required: true
|
||||
},
|
||||
userList: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
deptList: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formRef = ref()
|
||||
const selectedStartUsers = ref<UserVO[]>([])
|
||||
const selectedStartDepts = ref<DeptVO[]>([])
|
||||
const selectedManagerUsers = ref<UserVO[]>([])
|
||||
const userSelectFormRef = ref()
|
||||
const deptSelectFormRef = ref()
|
||||
const currentSelectType = ref<'start' | 'manager'>('start')
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '流程名称不能为空', trigger: 'blur' }],
|
||||
key: [{ required: true, message: '流程标识不能为空', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '流程分类不能为空', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }],
|
||||
visible: [{ required: true, message: '是否可见不能为空', trigger: 'blur' }],
|
||||
managerUserIds: [{ required: true, message: '流程管理员不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 创建本地数据副本
|
||||
const modelData = defineModel<any>()
|
||||
|
||||
// 初始化选中的用户
|
||||
watch(
|
||||
() => modelData.value,
|
||||
(newVal) => {
|
||||
if (newVal.startUserIds?.length) {
|
||||
selectedStartUsers.value = props.userList.filter((user: UserVO) =>
|
||||
newVal.startUserIds.includes(user.id)
|
||||
) as UserVO[]
|
||||
} else {
|
||||
selectedStartUsers.value = []
|
||||
}
|
||||
if (newVal.startDeptIds?.length) {
|
||||
selectedStartDepts.value = props.deptList.filter((dept: DeptVO) =>
|
||||
newVal.startDeptIds.includes(dept.id)
|
||||
) as DeptVO[]
|
||||
} else {
|
||||
selectedStartDepts.value = []
|
||||
}
|
||||
if (newVal.managerUserIds?.length) {
|
||||
selectedManagerUsers.value = props.userList.filter((user: UserVO) =>
|
||||
newVal.managerUserIds.includes(user.id)
|
||||
) as UserVO[]
|
||||
} else {
|
||||
selectedManagerUsers.value = []
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
/** 打开发起人选择 */
|
||||
const openStartUserSelect = () => {
|
||||
currentSelectType.value = 'start'
|
||||
userSelectFormRef.value.open(0, selectedStartUsers.value)
|
||||
}
|
||||
|
||||
/** 打开部门选择 */
|
||||
const openStartDeptSelect = () => {
|
||||
deptSelectFormRef.value.open(selectedStartDepts.value)
|
||||
}
|
||||
|
||||
/** 打开管理员选择 */
|
||||
const openManagerUserSelect = () => {
|
||||
currentSelectType.value = 'manager'
|
||||
userSelectFormRef.value.open(0, selectedManagerUsers.value)
|
||||
}
|
||||
|
||||
/** 处理用户选择确认 */
|
||||
const handleUserSelectConfirm = (_, users: UserVO[]) => {
|
||||
if (currentSelectType.value === 'start') {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startUserIds: users.map((u) => u.id)
|
||||
}
|
||||
} else {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
managerUserIds: users.map((u) => u.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理部门选择确认 */
|
||||
const handleDeptSelectConfirm = (depts: DeptVO[]) => {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startDeptIds: depts.map((d) => d.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理发起人类型变化 */
|
||||
const handleStartUserTypeChange = (value: number) => {
|
||||
if (value === 0) {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startUserIds: [],
|
||||
startDeptIds: []
|
||||
}
|
||||
} else if (value === 1) {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startDeptIds: []
|
||||
}
|
||||
} else if (value === 2) {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startUserIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除发起人 */
|
||||
const handleRemoveStartUser = (user: UserVO) => {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startUserIds: modelData.value.startUserIds.filter((id: number) => id !== user.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除部门 */
|
||||
const handleRemoveStartDept = (dept: DeptVO) => {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
startDeptIds: modelData.value.startDeptIds.filter((id: number) => id !== dept.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除管理员 */
|
||||
const handleRemoveManagerUser = (user: UserVO) => {
|
||||
modelData.value = {
|
||||
...modelData.value,
|
||||
managerUserIds: modelData.value.managerUserIds.filter((id: number) => id !== user.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单校验 */
|
||||
const validate = async () => {
|
||||
await formRef.value?.validate()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bg-gray-100 {
|
||||
background-color: #f5f7fa;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #e6e8eb;
|
||||
}
|
||||
|
||||
.ep-close {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
442
src/views/bpm/model/form/ExtraSettings.vue
Normal file
442
src/views/bpm/model/form/ExtraSettings.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :model="modelData" label-width="120px" class="mt-20px">
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">提交人权限</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<el-checkbox v-model="modelData.allowCancelRunningProcess" label="允许撤销审批中的申请" />
|
||||
<div class="ml-22px">
|
||||
<el-text type="info"> 第一个审批节点通过后,提交人仍可撤销申请 </el-text>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="modelData.processIdRule" class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">流程编码</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<el-input
|
||||
v-model="modelData.processIdRule.prefix"
|
||||
class="w-130px!"
|
||||
placeholder="前缀"
|
||||
:disabled="!modelData.processIdRule.enable"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-checkbox v-model="modelData.processIdRule.enable" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="modelData.processIdRule.infix"
|
||||
class="w-130px! ml-5px"
|
||||
placeholder="中缀"
|
||||
:disabled="!modelData.processIdRule.enable"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in timeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="modelData.processIdRule.postfix"
|
||||
class="w-80px! ml-5px"
|
||||
placeholder="后缀"
|
||||
:disabled="!modelData.processIdRule.enable"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="modelData.processIdRule.length"
|
||||
class="w-120px! ml-5px"
|
||||
:min="5"
|
||||
:disabled="!modelData.processIdRule.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-22px" v-if="modelData.processIdRule.enable">
|
||||
<el-text type="info"> 编码示例:{{ numberExample }} </el-text>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">自动去重</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<el-text> 同一审批人在流程中重复出现时: </el-text>
|
||||
</div>
|
||||
<el-radio-group v-model="modelData.autoApprovalType">
|
||||
<div class="flex flex-col">
|
||||
<el-radio :value="0">不自动通过</el-radio>
|
||||
<el-radio :value="1">仅审批一次,后续重复的审批节点均自动通过</el-radio>
|
||||
<el-radio :value="2">仅针对连续审批的节点自动通过</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="modelData.titleSetting" class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">标题设置</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<el-radio-group v-model="modelData.titleSetting.enable">
|
||||
<div class="flex flex-col">
|
||||
<el-radio :value="false"
|
||||
>系统默认 <el-text type="info"> 展示流程名称 </el-text></el-radio
|
||||
>
|
||||
<el-radio :value="true">
|
||||
自定义标题
|
||||
<el-text>
|
||||
<el-tooltip content="输入字符 '{' 即可插入表单字段" effect="light" placement="top">
|
||||
<Icon icon="ep:question-filled" class="ml-5px" />
|
||||
</el-tooltip>
|
||||
</el-text>
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
<el-mention
|
||||
v-if="modelData.titleSetting.enable"
|
||||
v-model="modelData.titleSetting.title"
|
||||
type="textarea"
|
||||
prefix="{"
|
||||
split="}"
|
||||
whole
|
||||
:options="formFieldOptions4Title"
|
||||
placeholder="请插入表单字段(输入 '{' 可以选择表单字段)或输入文本"
|
||||
class="w-600px!"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="modelData.summarySetting && modelData.formType === BpmModelFormType.NORMAL"
|
||||
class="mb-20px"
|
||||
>
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">摘要设置</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col">
|
||||
<el-radio-group v-model="modelData.summarySetting.enable">
|
||||
<div class="flex flex-col">
|
||||
<el-radio :value="false">
|
||||
系统默认 <el-text type="info"> 展示表单前 3 个字段 </el-text>
|
||||
</el-radio>
|
||||
<el-radio :value="true"> 自定义摘要 </el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
<el-select
|
||||
class="w-500px!"
|
||||
v-if="modelData.summarySetting.enable"
|
||||
v-model="modelData.summarySetting.summary"
|
||||
multiple
|
||||
placeholder="请选择要展示的表单字段"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in formFieldOptions4Summary"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">流程前置通知</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col w-100%">
|
||||
<div class="flex">
|
||||
<el-switch
|
||||
v-model="processBeforeTriggerEnable"
|
||||
@change="handleProcessBeforeTriggerEnableChange"
|
||||
/>
|
||||
<div class="ml-80px">流程启动后通知</div>
|
||||
</div>
|
||||
<HttpRequestSetting
|
||||
v-if="processBeforeTriggerEnable"
|
||||
v-model:setting="modelData.processBeforeTriggerSetting"
|
||||
:responseEnable="true"
|
||||
:formItemPrefix="'processBeforeTriggerSetting'"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">流程后置通知</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col w-100%">
|
||||
<div class="flex">
|
||||
<el-switch
|
||||
v-model="processAfterTriggerEnable"
|
||||
@change="handleProcessAfterTriggerEnableChange"
|
||||
/>
|
||||
<div class="ml-80px">流程结束后通知</div>
|
||||
</div>
|
||||
<HttpRequestSetting
|
||||
v-if="processAfterTriggerEnable"
|
||||
v-model:setting="modelData.processAfterTriggerSetting"
|
||||
:responseEnable="true"
|
||||
:formItemPrefix="'processAfterTriggerSetting'"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">任务前置通知</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col w-100%">
|
||||
<div class="flex">
|
||||
<el-switch
|
||||
v-model="taskBeforeTriggerEnable"
|
||||
@change="handleTaskBeforeTriggerEnableChange"
|
||||
/>
|
||||
<div class="ml-80px">任务执行时通知</div>
|
||||
</div>
|
||||
<HttpRequestSetting
|
||||
v-if="taskBeforeTriggerEnable"
|
||||
v-model:setting="modelData.taskBeforeTriggerSetting"
|
||||
:responseEnable="true"
|
||||
:formItemPrefix="'taskBeforeTriggerSetting'"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="mb-20px">
|
||||
<template #label>
|
||||
<el-text size="large" tag="b">任务后置通知</el-text>
|
||||
</template>
|
||||
<div class="flex flex-col w-100%">
|
||||
<div class="flex">
|
||||
<el-switch
|
||||
v-model="taskAfterTriggerEnable"
|
||||
@change="handleTaskAfterTriggerEnableChange"
|
||||
/>
|
||||
<div class="ml-80px">任务结束后通知</div>
|
||||
</div>
|
||||
<HttpRequestSetting
|
||||
v-if="taskAfterTriggerEnable"
|
||||
v-model:setting="modelData.taskAfterTriggerSetting"
|
||||
:responseEnable="true"
|
||||
:formItemPrefix="'taskAfterTriggerSetting'"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { BpmAutoApproveType, BpmModelFormType } from '@/utils/constants'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
import { parseFormFields } from '@/components/FormCreate/src/utils'
|
||||
import { ProcessVariableEnum } from '@/components/SimpleProcessDesignerV2/src/consts'
|
||||
import HttpRequestSetting from '@/components/SimpleProcessDesignerV2/src/nodes-config/components/HttpRequestSetting.vue'
|
||||
|
||||
const modelData = defineModel<any>()
|
||||
|
||||
/** 自定义 ID 流程编码 */
|
||||
const timeOptions = ref([
|
||||
{
|
||||
value: '',
|
||||
label: '无'
|
||||
},
|
||||
{
|
||||
value: 'DAY',
|
||||
label: '精确到日'
|
||||
},
|
||||
{
|
||||
value: 'HOUR',
|
||||
label: '精确到时'
|
||||
},
|
||||
{
|
||||
value: 'MINUTE',
|
||||
label: '精确到分'
|
||||
},
|
||||
{
|
||||
value: 'SECOND',
|
||||
label: '精确到秒'
|
||||
}
|
||||
])
|
||||
const numberExample = computed(() => {
|
||||
if (modelData.value.processIdRule.enable) {
|
||||
let infix = ''
|
||||
switch (modelData.value.processIdRule.infix) {
|
||||
case 'DAY':
|
||||
infix = dayjs().format('YYYYMMDD')
|
||||
break
|
||||
case 'HOUR':
|
||||
infix = dayjs().format('YYYYMMDDHH')
|
||||
break
|
||||
case 'MINUTE':
|
||||
infix = dayjs().format('YYYYMMDDHHmm')
|
||||
break
|
||||
case 'SECOND':
|
||||
infix = dayjs().format('YYYYMMDDHHmmss')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return (
|
||||
modelData.value.processIdRule.prefix +
|
||||
infix +
|
||||
modelData.value.processIdRule.postfix +
|
||||
'1'.padStart(modelData.value.processIdRule.length - 1, '0')
|
||||
)
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
/** 是否开启流程前置通知 */
|
||||
const processBeforeTriggerEnable = ref(false)
|
||||
const handleProcessBeforeTriggerEnableChange = (val: boolean | string | number) => {
|
||||
if (val) {
|
||||
modelData.value.processBeforeTriggerSetting = {
|
||||
url: '',
|
||||
header: [],
|
||||
body: [],
|
||||
response: []
|
||||
}
|
||||
} else {
|
||||
modelData.value.processBeforeTriggerSetting = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否开启流程后置通知 */
|
||||
const processAfterTriggerEnable = ref(false)
|
||||
const handleProcessAfterTriggerEnableChange = (val: boolean | string | number) => {
|
||||
if (val) {
|
||||
modelData.value.processAfterTriggerSetting = {
|
||||
url: '',
|
||||
header: [],
|
||||
body: [],
|
||||
response: []
|
||||
}
|
||||
} else {
|
||||
modelData.value.processAfterTriggerSetting = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否开启任务前置通知 */
|
||||
const taskBeforeTriggerEnable = ref(false)
|
||||
const handleTaskBeforeTriggerEnableChange = (val: boolean | string | number) => {
|
||||
if (val) {
|
||||
modelData.value.taskBeforeTriggerSetting = {
|
||||
url: '',
|
||||
header: [],
|
||||
body: [],
|
||||
response: []
|
||||
}
|
||||
} else {
|
||||
modelData.value.taskBeforeTriggerSetting = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否开启任务后置通知 */
|
||||
const taskAfterTriggerEnable = ref(false)
|
||||
const handleTaskAfterTriggerEnableChange = (val: boolean | string | number) => {
|
||||
if (val) {
|
||||
modelData.value.taskAfterTriggerSetting = {
|
||||
url: '',
|
||||
header: [],
|
||||
body: [],
|
||||
response: []
|
||||
}
|
||||
} else {
|
||||
modelData.value.taskAfterTriggerSetting = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单选项 */
|
||||
const formField = ref<Array<{ field: string; title: string }>>([])
|
||||
const formFieldOptions4Title = computed(() => {
|
||||
let cloneFormField = formField.value.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.field
|
||||
}
|
||||
})
|
||||
// 固定添加发起人 ID 字段
|
||||
cloneFormField.unshift({
|
||||
label: '流程名称',
|
||||
value: ProcessVariableEnum.PROCESS_DEFINITION_NAME
|
||||
})
|
||||
cloneFormField.unshift({
|
||||
label: '发起时间',
|
||||
value: ProcessVariableEnum.START_TIME
|
||||
})
|
||||
cloneFormField.unshift({
|
||||
label: '发起人',
|
||||
value: ProcessVariableEnum.START_USER_ID
|
||||
})
|
||||
return cloneFormField
|
||||
})
|
||||
const formFieldOptions4Summary = computed(() => {
|
||||
return formField.value.map((item) => {
|
||||
return {
|
||||
label: item.title,
|
||||
value: item.field
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/** 兼容以前未配置更多设置的流程 */
|
||||
const initData = () => {
|
||||
if (!modelData.value.processIdRule) {
|
||||
modelData.value.processIdRule = {
|
||||
enable: false,
|
||||
prefix: '',
|
||||
infix: '',
|
||||
postfix: '',
|
||||
length: 5
|
||||
}
|
||||
}
|
||||
if (!modelData.value.autoApprovalType) {
|
||||
modelData.value.autoApprovalType = BpmAutoApproveType.NONE
|
||||
}
|
||||
if (!modelData.value.titleSetting) {
|
||||
modelData.value.titleSetting = {
|
||||
enable: false,
|
||||
title: ''
|
||||
}
|
||||
}
|
||||
if (!modelData.value.summarySetting) {
|
||||
modelData.value.summarySetting = {
|
||||
enable: false,
|
||||
summary: []
|
||||
}
|
||||
}
|
||||
if (modelData.value.processBeforeTriggerSetting) {
|
||||
processBeforeTriggerEnable.value = true
|
||||
}
|
||||
if (modelData.value.processAfterTriggerSetting) {
|
||||
processAfterTriggerEnable.value = true
|
||||
}
|
||||
if (modelData.value.taskBeforeTriggerSetting) {
|
||||
taskBeforeTriggerEnable.value = true
|
||||
}
|
||||
if (modelData.value.taskAfterTriggerSetting) {
|
||||
taskAfterTriggerEnable.value = true
|
||||
}
|
||||
}
|
||||
defineExpose({ initData })
|
||||
|
||||
/** 监听表单 ID 变化,加载表单数据 */
|
||||
watch(
|
||||
() => modelData.value.formId,
|
||||
async (newFormId) => {
|
||||
if (newFormId && modelData.value.formType === BpmModelFormType.NORMAL) {
|
||||
const data = await FormApi.getForm(newFormId)
|
||||
const result: Array<{ field: string; title: string }> = []
|
||||
if (data.fields) {
|
||||
data.fields.forEach((fieldStr: string) => {
|
||||
parseFormFields(JSON.parse(fieldStr), result)
|
||||
})
|
||||
}
|
||||
formField.value = result
|
||||
} else {
|
||||
formField.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
129
src/views/bpm/model/form/FormDesign.vue
Normal file
129
src/views/bpm/model/form/FormDesign.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<el-form ref="formRef" :model="modelData" :rules="rules" label-width="120px" class="mt-20px">
|
||||
<el-form-item label="表单类型" prop="formType" class="mb-20px">
|
||||
<el-radio-group v-model="modelData.formType">
|
||||
<el-radio
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_FORM_TYPE)"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>
|
||||
{{ dict.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="modelData.formType === BpmModelFormType.NORMAL" label="流程表单" prop="formId">
|
||||
<el-select v-model="modelData.formId" clearable style="width: 100%">
|
||||
<el-option v-for="form in formList" :key="form.id" :label="form.name" :value="form.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="modelData.formType === BpmModelFormType.CUSTOM" label="表单提交路由" prop="formCustomCreatePath">
|
||||
<el-input
|
||||
v-model="modelData.formCustomCreatePath"
|
||||
placeholder="请输入表单提交路由"
|
||||
style="width: 330px"
|
||||
/>
|
||||
<el-tooltip
|
||||
class="item"
|
||||
content="自定义表单的提交路径,使用 Vue 的路由地址,例如说:bpm/oa/leave/create.vue"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<Icon icon="ep:question" class="ml-5px" />
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="modelData.formType === BpmModelFormType.CUSTOM" label="表单查看地址" prop="formCustomViewPath">
|
||||
<el-input
|
||||
v-model="modelData.formCustomViewPath"
|
||||
placeholder="请输入表单查看的组件地址"
|
||||
style="width: 330px"
|
||||
/>
|
||||
<el-tooltip
|
||||
class="item"
|
||||
content="自定义表单的查看组件地址,使用 Vue 的组件地址,例如说:bpm/oa/leave/detail.vue"
|
||||
effect="light"
|
||||
placement="top"
|
||||
>
|
||||
<Icon icon="ep:question" class="ml-5px" />
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<!-- 表单预览 -->
|
||||
<div
|
||||
v-if="modelData.formType === BpmModelFormType.NORMAL && modelData.formId && formPreview.rule.length > 0"
|
||||
class="mt-20px"
|
||||
>
|
||||
<div class="flex items-center mb-15px">
|
||||
<div class="h-15px w-4px bg-[#1890ff] mr-10px"></div>
|
||||
<span class="text-15px font-bold">表单预览</span>
|
||||
</div>
|
||||
<form-create
|
||||
v-model="formPreview.formData"
|
||||
:rule="formPreview.rule"
|
||||
:option="formPreview.option"
|
||||
/>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
import { setConfAndFields2 } from '@/utils/formCreate'
|
||||
import { BpmModelFormType } from '@/utils/constants'
|
||||
|
||||
const props = defineProps({
|
||||
formList: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
// 创建本地数据副本
|
||||
const modelData = defineModel<any>()
|
||||
|
||||
// 表单预览数据
|
||||
const formPreview = ref({
|
||||
formData: {},
|
||||
rule: [],
|
||||
option: {
|
||||
submitBtn: false,
|
||||
resetBtn: false,
|
||||
formData: {}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听表单ID变化,加载表单数据
|
||||
watch(
|
||||
() => modelData.value.formId,
|
||||
async (newFormId) => {
|
||||
if (newFormId && modelData.value.formType === BpmModelFormType.NORMAL) {
|
||||
const data = await FormApi.getForm(newFormId)
|
||||
setConfAndFields2(formPreview.value, data.conf, data.fields)
|
||||
// 设置只读
|
||||
formPreview.value.rule.forEach((item: any) => {
|
||||
item.props = { ...item.props, disabled: true }
|
||||
})
|
||||
} else {
|
||||
formPreview.value.rule = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const rules = {
|
||||
formType: [{ required: true, message: '表单类型不能为空', trigger: 'blur' }],
|
||||
formId: [{ required: true, message: '流程表单不能为空', trigger: 'blur' }],
|
||||
formCustomCreatePath: [{ required: true, message: '表单提交路由不能为空', trigger: 'blur' }],
|
||||
formCustomViewPath: [{ required: true, message: '表单查看地址不能为空', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
/** 表单校验 */
|
||||
const validate = async () => {
|
||||
await formRef.value?.validate()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate
|
||||
})
|
||||
</script>
|
||||
72
src/views/bpm/model/form/ProcessDesign.vue
Normal file
72
src/views/bpm/model/form/ProcessDesign.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<!-- BPMN设计器 -->
|
||||
<template v-if="modelData.type === BpmModelType.BPMN">
|
||||
<BpmModelEditor
|
||||
v-if="showDesigner"
|
||||
:model-id="modelData.id"
|
||||
:model-key="modelData.key"
|
||||
:model-name="modelData.name"
|
||||
@success="handleDesignSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Simple设计器 -->
|
||||
<template v-else>
|
||||
<SimpleModelDesign
|
||||
v-if="showDesigner"
|
||||
:model-id="modelData.id"
|
||||
:model-key="modelData.key"
|
||||
:model-name="modelData.name"
|
||||
:start-user-ids="modelData.startUserIds"
|
||||
:start-dept-ids="modelData.startDeptIds"
|
||||
@success="handleDesignSuccess"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { BpmModelType } from '@/utils/constants'
|
||||
import BpmModelEditor from './editor/index.vue'
|
||||
import SimpleModelDesign from '../../simple/SimpleModelDesign.vue'
|
||||
|
||||
// 创建本地数据副本
|
||||
const modelData = defineModel<any>()
|
||||
|
||||
const processData = inject('processData') as Ref
|
||||
|
||||
/** 表单校验 */
|
||||
const validate = async () => {
|
||||
try {
|
||||
// 获取最新的流程数据
|
||||
if (!processData.value) {
|
||||
throw new Error('请设计流程')
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
/** 处理设计器保存成功 */
|
||||
const handleDesignSuccess = async (data?: any) => {
|
||||
if (data) {
|
||||
// 创建新的对象以触发响应式更新
|
||||
const newModelData = {
|
||||
...modelData.value,
|
||||
bpmnXml: modelData.value.type === BpmModelType.BPMN ? data : null,
|
||||
simpleModel: modelData.value.type === BpmModelType.BPMN ? null : data
|
||||
}
|
||||
// 使用emit更新父组件的数据
|
||||
await nextTick()
|
||||
//更新表单的模型数据部分
|
||||
modelData.value = newModelData
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否显示设计器 */
|
||||
const showDesigner = computed(() => {
|
||||
return Boolean(modelData.value?.key && modelData.value?.name)
|
||||
})
|
||||
defineExpose({
|
||||
validate
|
||||
})
|
||||
</script>
|
||||
124
src/views/bpm/model/form/editor/index.vue
Normal file
124
src/views/bpm/model/form/editor/index.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 流程设计器,负责绘制流程等 -->
|
||||
<MyProcessDesigner
|
||||
key="designer"
|
||||
v-model="xmlString"
|
||||
:value="xmlString"
|
||||
v-bind="controlForm"
|
||||
keyboard
|
||||
ref="processDesigner"
|
||||
@init-finished="initModeler"
|
||||
:additionalModel="controlForm.additionalModel"
|
||||
:model="model"
|
||||
@save="save"
|
||||
:process-id="modelKey"
|
||||
:process-name="modelName"
|
||||
/>
|
||||
<!-- 流程属性器,负责编辑每个流程节点的属性 -->
|
||||
<MyProcessPenal
|
||||
v-if="modeler"
|
||||
key="penal"
|
||||
:bpmnModeler="modeler"
|
||||
:prefix="controlForm.prefix"
|
||||
class="process-panel"
|
||||
:model="model"
|
||||
/>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MyProcessDesigner, MyProcessPenal } from '@/components/bpmnProcessDesigner/package'
|
||||
// 自定义元素选中时的弹出菜单(修改 默认任务 为 用户任务)
|
||||
import CustomContentPadProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/content-pad'
|
||||
// 自定义左侧菜单(修改 默认任务 为 用户任务)
|
||||
import CustomPaletteProvider from '@/components/bpmnProcessDesigner/package/designer/plugins/palette'
|
||||
import * as ModelApi from '@/api/bpm/model'
|
||||
import { BpmModelFormType } from '@/utils/constants'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
|
||||
defineOptions({ name: 'BpmModelEditor' })
|
||||
|
||||
defineProps<{
|
||||
modelId?: string
|
||||
modelKey: string
|
||||
modelName: string
|
||||
value?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['success', 'init-finished'])
|
||||
const message = useMessage() // 国际化
|
||||
|
||||
// 表单信息
|
||||
const formFields = ref<string[]>([])
|
||||
// 表单类型,暂仅限流程表单
|
||||
const formType = ref(BpmModelFormType.NORMAL)
|
||||
provide('formFields', formFields)
|
||||
provide('formType', formType)
|
||||
|
||||
// 注入流程数据
|
||||
const xmlString = inject('processData') as Ref
|
||||
// 注入模型数据
|
||||
const modelData = inject('modelData') as Ref
|
||||
|
||||
const modeler = shallowRef() // BPMN Modeler
|
||||
const processDesigner = ref()
|
||||
const controlForm = ref({
|
||||
simulation: true,
|
||||
labelEditing: false,
|
||||
labelVisible: false,
|
||||
prefix: 'flowable',
|
||||
headerButtonSize: 'mini',
|
||||
additionalModel: [CustomContentPadProvider, CustomPaletteProvider]
|
||||
})
|
||||
const model = ref<ModelApi.ModelVO>() // 流程模型的信息
|
||||
|
||||
/** 初始化 modeler */
|
||||
const initModeler = async (item: any) => {
|
||||
// 先初始化模型数据
|
||||
model.value = modelData.value
|
||||
modeler.value = item
|
||||
}
|
||||
|
||||
/** 添加/修改模型 */
|
||||
const save = async (bpmnXml: string) => {
|
||||
try {
|
||||
xmlString.value = bpmnXml
|
||||
emit('success', bpmnXml)
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
message.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听表单 ID 变化,加载表单数据 */
|
||||
watch(
|
||||
() => modelData.value.formId,
|
||||
async (newFormId) => {
|
||||
if (newFormId && modelData.value.formType === BpmModelFormType.NORMAL) {
|
||||
const data = await FormApi.getForm(newFormId)
|
||||
formFields.value = data.fields
|
||||
} else {
|
||||
formFields.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 在组件卸载时清理
|
||||
onBeforeUnmount(() => {
|
||||
modeler.value = null
|
||||
// 清理全局实例
|
||||
const w = window as any
|
||||
if (w.bpmnInstances) {
|
||||
w.bpmnInstances = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.process-panel__container {
|
||||
position: absolute;
|
||||
top: 172px;
|
||||
right: 70px;
|
||||
}
|
||||
</style>
|
||||
442
src/views/bpm/model/form/index.vue
Normal file
442
src/views/bpm/model/form/index.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<div class="mx-auto">
|
||||
<!-- 头部导航栏 -->
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 h-50px bg-white border-bottom z-10 flex items-center px-20px"
|
||||
>
|
||||
<!-- 左侧标题 -->
|
||||
<div class="w-200px flex items-center overflow-hidden">
|
||||
<Icon icon="ep:arrow-left" class="cursor-pointer flex-shrink-0" @click="handleBack" />
|
||||
<span class="ml-10px text-16px truncate" :title="formData.name || '创建流程'">
|
||||
{{ formData.name || '创建流程' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 步骤条 -->
|
||||
<div class="flex-1 flex items-center justify-center h-full">
|
||||
<div class="w-400px flex items-center justify-between h-full">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-center cursor-pointer mx-15px relative h-full"
|
||||
:class="[
|
||||
currentStep === index
|
||||
? 'text-[#3473ff] border-[#3473ff] border-b-2 border-b-solid'
|
||||
: 'text-gray-500'
|
||||
]"
|
||||
@click="handleStepClick(index)"
|
||||
>
|
||||
<div
|
||||
class="w-28px h-28px rounded-full flex items-center justify-center mr-8px border-2 border-solid text-15px"
|
||||
:class="[
|
||||
currentStep === index
|
||||
? 'bg-[#3473ff] text-white border-[#3473ff]'
|
||||
: 'border-gray-300 bg-white text-gray-500'
|
||||
]"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<span class="text-16px font-bold whitespace-nowrap">{{ step.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧按钮 -->
|
||||
<div class="w-200px flex items-center justify-end gap-2">
|
||||
<el-button v-if="actionType === 'update'" type="success" @click="handleDeploy">
|
||||
发 布
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleSave">
|
||||
<span v-if="actionType === 'definition'">恢 复</span>
|
||||
<span v-else>保 存</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="mt-50px">
|
||||
<!-- 第一步:基本信息 -->
|
||||
<div v-if="currentStep === 0" class="mx-auto w-560px">
|
||||
<BasicInfo
|
||||
v-model="formData"
|
||||
:categoryList="categoryList"
|
||||
:userList="userList"
|
||||
:deptList="deptList"
|
||||
ref="basicInfoRef"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 第二步:表单设计 -->
|
||||
<div v-if="currentStep === 1" class="mx-auto w-560px">
|
||||
<FormDesign v-model="formData" :formList="formList" ref="formDesignRef" />
|
||||
</div>
|
||||
|
||||
<!-- 第三步:流程设计 -->
|
||||
<ProcessDesign v-if="currentStep === 2" v-model="formData" ref="processDesignRef" />
|
||||
|
||||
<!-- 第四步:更多设置 -->
|
||||
<div v-show="currentStep === 3" class="mx-auto w-700px">
|
||||
<ExtraSettings v-model="formData" ref="extraSettingsRef" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||
import * as ModelApi from '@/api/bpm/model'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
||||
import * as UserApi from '@/api/system/user'
|
||||
import * as DeptApi from '@/api/system/dept'
|
||||
import * as DefinitionApi from '@/api/bpm/definition'
|
||||
import { BpmModelFormType, BpmModelType, BpmAutoApproveType } from '@/utils/constants'
|
||||
import BasicInfo from './BasicInfo.vue'
|
||||
import FormDesign from './FormDesign.vue'
|
||||
import ProcessDesign from './ProcessDesign.vue'
|
||||
import ExtraSettings from './ExtraSettings.vue'
|
||||
import { useTagsView } from '@/hooks/web/useTagsView'
|
||||
|
||||
const router = useRouter()
|
||||
const { delView } = useTagsViewStore() // 视图操作
|
||||
const tagsView = useTagsView()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const userStore = useUserStoreWithOut()
|
||||
|
||||
// 组件引用
|
||||
const basicInfoRef = ref()
|
||||
const formDesignRef = ref()
|
||||
const processDesignRef = ref()
|
||||
const extraSettingsRef = ref()
|
||||
|
||||
/** 步骤校验函数 */
|
||||
const validateBasic = async () => {
|
||||
await basicInfoRef.value?.validate()
|
||||
}
|
||||
|
||||
/** 表单设计校验 */
|
||||
const validateForm = async () => {
|
||||
await formDesignRef.value?.validate()
|
||||
}
|
||||
|
||||
/** 流程设计校验 */
|
||||
const validateProcess = async () => {
|
||||
await processDesignRef.value?.validate()
|
||||
}
|
||||
|
||||
const currentStep = ref(-1) // 步骤控制。-1 用于,一开始全部不展示等当前页面数据初始化完成
|
||||
|
||||
const steps = [
|
||||
{ title: '基本信息', validator: validateBasic },
|
||||
{ title: '表单设计', validator: validateForm },
|
||||
{ title: '流程设计', validator: validateProcess },
|
||||
{ title: '更多设置', validator: null }
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const formData: any = ref({
|
||||
id: undefined,
|
||||
name: '',
|
||||
key: '',
|
||||
category: undefined,
|
||||
icon: undefined,
|
||||
description: '',
|
||||
type: BpmModelType.BPMN,
|
||||
formType: BpmModelFormType.NORMAL,
|
||||
formId: '',
|
||||
formCustomCreatePath: '',
|
||||
formCustomViewPath: '',
|
||||
visible: true,
|
||||
startUserType: undefined,
|
||||
startUserIds: [],
|
||||
startDeptIds: [],
|
||||
managerUserIds: [],
|
||||
allowCancelRunningProcess: true,
|
||||
processIdRule: {
|
||||
enable: false,
|
||||
prefix: '',
|
||||
infix: '',
|
||||
postfix: '',
|
||||
length: 5
|
||||
},
|
||||
autoApprovalType: BpmAutoApproveType.NONE,
|
||||
titleSetting: {
|
||||
enable: false,
|
||||
title: ''
|
||||
},
|
||||
summarySetting: {
|
||||
enable: false,
|
||||
summary: []
|
||||
}
|
||||
})
|
||||
|
||||
// 流程数据
|
||||
const processData = ref<any>()
|
||||
|
||||
provide('processData', processData)
|
||||
provide('modelData', formData)
|
||||
|
||||
// 数据列表
|
||||
const formList = ref([])
|
||||
const categoryList = ref<CategoryVO[]>([])
|
||||
const userList = ref<UserApi.UserVO[]>([])
|
||||
const deptList = ref<DeptApi.DeptVO[]>([])
|
||||
|
||||
/** 初始化数据 */
|
||||
const actionType = route.params.type as string
|
||||
const initData = async () => {
|
||||
if (actionType === 'definition') {
|
||||
// 情况一:流程定义场景(恢复)
|
||||
const definitionId = route.params.id as string
|
||||
const data = await DefinitionApi.getProcessDefinition(definitionId)
|
||||
// 将 definition => model,最终赋值
|
||||
data.type = data.modelType
|
||||
delete data.modelType
|
||||
data.id = data.modelId
|
||||
delete data.modelId
|
||||
if (data.simpleModel) {
|
||||
data.simpleModel = JSON.parse(data.simpleModel)
|
||||
}
|
||||
formData.value = data
|
||||
formData.value.startUserType =
|
||||
formData.value.startUserIds?.length > 0 ? 1 : formData.value?.startDeptIds?.length > 0 ? 2 : 0
|
||||
} else if (['update', 'copy'].includes(actionType)) {
|
||||
// 情况二:修改场景/复制场景
|
||||
const modelId = route.params.id as string
|
||||
formData.value = await ModelApi.getModel(modelId)
|
||||
formData.value.startUserType =
|
||||
formData.value.startUserIds?.length > 0 ? 1 : formData.value?.startDeptIds?.length > 0 ? 2 : 0
|
||||
|
||||
// 特殊:复制场景
|
||||
if (route.params.type === 'copy') {
|
||||
delete formData.value.id
|
||||
formData.value.name += '副本'
|
||||
formData.value.key += '_copy'
|
||||
tagsView.setTitle('复制流程')
|
||||
}
|
||||
} else {
|
||||
// 情况三:新增场景
|
||||
formData.value.startUserType = 0 // 全体
|
||||
formData.value.managerUserIds.push(userStore.getUser.id)
|
||||
}
|
||||
|
||||
// 获取表单列表
|
||||
formList.value = await FormApi.getFormSimpleList()
|
||||
// 获取分类列表
|
||||
categoryList.value = await CategoryApi.getCategorySimpleList()
|
||||
// 获取用户列表
|
||||
userList.value = await UserApi.getSimpleUserList()
|
||||
// 获取部门列表
|
||||
deptList.value = await DeptApi.getSimpleDeptList()
|
||||
|
||||
// 最终,设置 currentStep 切换到第一步
|
||||
currentStep.value = 0
|
||||
|
||||
// 兼容,以前未配置更多设置的流程
|
||||
extraSettingsRef.value.initData()
|
||||
}
|
||||
|
||||
/** 根据类型切换流程数据 */
|
||||
watch(
|
||||
async () => formData.value.type,
|
||||
() => {
|
||||
if (formData.value.type === BpmModelType.BPMN) {
|
||||
processData.value = formData.value.bpmnXml
|
||||
} else if (formData.value.type === BpmModelType.SIMPLE) {
|
||||
processData.value = formData.value.simpleModel
|
||||
}
|
||||
console.log('加载流程数据', processData.value)
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
)
|
||||
|
||||
/** 校验所有步骤数据是否完整 */
|
||||
const validateAllSteps = async () => {
|
||||
try {
|
||||
// 基本信息校验
|
||||
try {
|
||||
await validateBasic()
|
||||
} catch (error) {
|
||||
currentStep.value = 0
|
||||
throw new Error('请完善基本信息')
|
||||
}
|
||||
|
||||
// 表单设计校验
|
||||
try {
|
||||
await validateForm()
|
||||
} catch (error) {
|
||||
currentStep.value = 1
|
||||
throw new Error('请完善自定义表单信息')
|
||||
}
|
||||
|
||||
// 流程设计校验
|
||||
|
||||
// 表单设计校验
|
||||
try {
|
||||
await validateProcess()
|
||||
} catch (error) {
|
||||
currentStep.value = 2
|
||||
throw new Error('请设计流程')
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存操作 */
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 保存前校验所有步骤的数据
|
||||
await validateAllSteps()
|
||||
|
||||
// 更新表单数据
|
||||
const modelData = {
|
||||
...formData.value
|
||||
}
|
||||
|
||||
if (actionType === 'definition') {
|
||||
// 情况一:流程定义场景(恢复)
|
||||
await ModelApi.updateModel(modelData)
|
||||
// 提示成功
|
||||
message.success('恢复成功,可点击【发布】按钮,进行发布模型')
|
||||
} else if (actionType === 'update') {
|
||||
// 修改场景
|
||||
await ModelApi.updateModel(modelData)
|
||||
// 提示成功
|
||||
message.success('修改成功,可点击【发布】按钮,进行发布模型')
|
||||
} else if (actionType === 'copy') {
|
||||
// 情况三:复制场景
|
||||
formData.value.id = await ModelApi.createModel(modelData)
|
||||
// 提示成功
|
||||
message.success('复制成功,可点击【发布】按钮,进行发布模型')
|
||||
} else {
|
||||
// 情况四:新增场景
|
||||
formData.value.id = await ModelApi.createModel(modelData)
|
||||
// 提示成功
|
||||
message.success('新建成功,可点击【发布】按钮,进行发布模型')
|
||||
}
|
||||
|
||||
// 返回列表页(排除更新的情况)
|
||||
if (actionType !== 'update') {
|
||||
await router.push({ name: 'BpmModel' })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error)
|
||||
message.warning(error.message || '请完善所有步骤的必填信息')
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布操作 */
|
||||
const handleDeploy = async () => {
|
||||
try {
|
||||
// 修改场景下直接发布,新增场景下需要先确认
|
||||
if (!formData.value.id) {
|
||||
await message.confirm('是否确认发布该流程?')
|
||||
}
|
||||
// 校验所有步骤
|
||||
await validateAllSteps()
|
||||
|
||||
// 更新表单数据
|
||||
const modelData = {
|
||||
...formData.value
|
||||
}
|
||||
|
||||
// 先保存所有数据
|
||||
if (formData.value.id) {
|
||||
await ModelApi.updateModel(modelData)
|
||||
} else {
|
||||
const result = await ModelApi.createModel(modelData)
|
||||
formData.value.id = result.id
|
||||
}
|
||||
|
||||
// 发布
|
||||
await ModelApi.deployModel(formData.value.id)
|
||||
message.success('发布成功')
|
||||
// 返回列表页
|
||||
await router.push({ name: 'BpmModel' })
|
||||
} catch (error: any) {
|
||||
console.error('发布失败:', error)
|
||||
message.warning(error.message || '发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 步骤切换处理 */
|
||||
const handleStepClick = async (index: number) => {
|
||||
try {
|
||||
if (index !== 0) {
|
||||
await validateBasic()
|
||||
}
|
||||
if (index !== 1) {
|
||||
await validateForm()
|
||||
}
|
||||
if (index !== 2) {
|
||||
await validateProcess()
|
||||
}
|
||||
|
||||
// 切换步骤
|
||||
currentStep.value = index
|
||||
|
||||
// 如果切换到流程设计步骤,等待组件渲染完成后刷新设计器
|
||||
if (index === 2) {
|
||||
await nextTick()
|
||||
// 等待更长时间确保组件完全初始化
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
if (processDesignRef.value?.refresh) {
|
||||
await processDesignRef.value.refresh()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('步骤切换失败:', error)
|
||||
message.warning('请先完善当前步骤必填信息')
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回列表页 */
|
||||
const handleBack = () => {
|
||||
// 先删除当前页签
|
||||
delView(unref(router.currentRoute))
|
||||
// 跳转到列表页
|
||||
router.push({ name: 'BpmModel' })
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
await initData()
|
||||
})
|
||||
|
||||
// 添加组件卸载前的清理代码
|
||||
onBeforeUnmount(() => {
|
||||
// 清理所有的引用
|
||||
basicInfoRef.value = null
|
||||
formDesignRef.value = null
|
||||
processDesignRef.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.border-bottom {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #3473ff;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: #3473ff;
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: #3473ff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,404 +1,225 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form-item label="流程标识" prop="key">
|
||||
<el-input
|
||||
v-model="queryParams.key"
|
||||
placeholder="请输入流程标识"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程名称" prop="name">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="请输入流程名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="流程分类" prop="category">
|
||||
<el-select
|
||||
v-model="queryParams.category"
|
||||
placeholder="请选择流程分类"
|
||||
clearable
|
||||
class="!w-240px"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.BPM_MODEL_CATEGORY)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="openForm('create')"
|
||||
v-hasPermi="['bpm:model:create']"
|
||||
>
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新建流程
|
||||
</el-button>
|
||||
<el-button type="success" plain @click="openImportForm" v-hasPermi="['bpm:model:import']">
|
||||
<Icon icon="ep:upload" class="mr-5px" /> 导入流程
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
<div class="flex justify-between pl-20px items-center">
|
||||
<h3 class="font-extrabold">流程模型</h3>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
v-if="!isCategorySorting"
|
||||
class="-mb-15px flex mr-10px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item prop="name" class="ml-auto">
|
||||
<el-input
|
||||
v-model="queryParams.name"
|
||||
placeholder="搜索流程"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="ep:search" class="mx-10px" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<!-- 右上角:新建模型、更多操作 -->
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="openForm('create')" v-hasPermi="['bpm:model:create']">
|
||||
<Icon icon="ep:plus" class="mr-5px" /> 新建模型
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-dropdown @command="(command) => handleCommand(command)" placement="bottom-end">
|
||||
<el-button class="w-30px" plain>
|
||||
<Icon icon="ep:setting" />
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="handleCategoryAdd">
|
||||
<Icon icon="ep:circle-plus" :size="13" class="mr-5px" />
|
||||
新建分类
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="handleCategorySort">
|
||||
<Icon icon="fa:sort-amount-desc" :size="13" class="mr-5px" />
|
||||
分类排序
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="mr-20px" v-else>
|
||||
<el-button @click="handleCategorySortCancel"> 取 消 </el-button>
|
||||
<el-button type="primary" @click="handleCategorySortSubmit"> 保存排序 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list">
|
||||
<el-table-column label="流程标识" align="center" prop="key" width="200" />
|
||||
<el-table-column label="流程名称" align="center" prop="name" width="200">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link @click="handleBpmnDetail(scope.row)">
|
||||
<span>{{ scope.row.name }}</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="流程分类" align="center" prop="category" width="100">
|
||||
<template #default="scope">
|
||||
<dict-tag :type="DICT_TYPE.BPM_MODEL_CATEGORY" :value="scope.row.category" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="表单信息" align="center" prop="formType" width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.formType === 10"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleFormDetail(scope.row)"
|
||||
<el-divider />
|
||||
|
||||
<!-- 按照分类,展示其所属的模型列表 -->
|
||||
<div class="px-15px">
|
||||
<draggable
|
||||
:disabled="!isCategorySorting"
|
||||
v-model="categoryGroup"
|
||||
item-key="id"
|
||||
:animation="400"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<ContentWrap
|
||||
class="rounded-lg transition-all duration-300 ease-in-out hover:shadow-xl"
|
||||
v-loading="loading"
|
||||
:body-style="{ padding: 0 }"
|
||||
:key="element.id"
|
||||
>
|
||||
<span>{{ scope.row.formName }}</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="scope.row.formType === 20"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleFormDetail(scope.row)"
|
||||
>
|
||||
<span>{{ scope.row.formCustomCreatePath }}</span>
|
||||
</el-button>
|
||||
<label v-else>暂无表单</label>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="创建时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="最新部署的流程定义" align="center">
|
||||
<el-table-column
|
||||
label="流程版本"
|
||||
align="center"
|
||||
prop="processDefinition.version"
|
||||
width="100"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.processDefinition">
|
||||
v{{ scope.row.processDefinition.version }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="warning">未部署</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="激活状态"
|
||||
align="center"
|
||||
prop="processDefinition.version"
|
||||
width="85"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-if="scope.row.processDefinition"
|
||||
v-model="scope.row.processDefinition.suspensionState"
|
||||
:active-value="1"
|
||||
:inactive-value="2"
|
||||
@change="handleChangeState(scope.row)"
|
||||
<CategoryDraggableModel
|
||||
:isCategorySorting="isCategorySorting"
|
||||
:categoryInfo="element"
|
||||
@success="getList"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部署时间" align="center" prop="deploymentTime" width="180">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.processDefinition">
|
||||
{{ formatDate(scope.row.processDefinition.deploymentTime) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="240" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openForm('update', scope.row.id)"
|
||||
v-hasPermi="['bpm:model:update']"
|
||||
>
|
||||
修改流程
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleDesign(scope.row)"
|
||||
v-hasPermi="['bpm:model:update']"
|
||||
>
|
||||
设计流程
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleAssignRule(scope.row)"
|
||||
v-hasPermi="['bpm:task-assign-rule:query']"
|
||||
>
|
||||
分配规则
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="handleDeploy(scope.row)"
|
||||
v-hasPermi="['bpm:model:deploy']"
|
||||
>
|
||||
发布流程
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
v-hasPermi="['bpm:process-definition:query']"
|
||||
@click="handleDefinitionList(scope.row)"
|
||||
>
|
||||
流程定义
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['bpm:model:delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</ContentWrap>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</draggable>
|
||||
</div>
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改流程 -->
|
||||
<ModelForm ref="formRef" @success="getList" />
|
||||
|
||||
<!-- 表单弹窗:导入流程 -->
|
||||
<ModelImportForm ref="importFormRef" @success="getList" />
|
||||
|
||||
<!-- 表单弹窗:添加分类 -->
|
||||
<CategoryForm ref="categoryFormRef" @success="getList" />
|
||||
<!-- 弹窗:表单详情 -->
|
||||
<Dialog title="表单详情" v-model="formDetailVisible" width="800">
|
||||
<form-create :rule="formDetailPreview.rule" :option="formDetailPreview.option" />
|
||||
</Dialog>
|
||||
|
||||
<!-- 弹窗:流程模型图的预览 -->
|
||||
<Dialog title="流程图" v-model="bpmnDetailVisible" width="800">
|
||||
<MyProcessViewer
|
||||
key="designer"
|
||||
v-model="bpmnXML"
|
||||
:value="bpmnXML as any"
|
||||
v-bind="bpmnControlForm"
|
||||
:prefix="bpmnControlForm.prefix"
|
||||
/>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||
import { dateFormatter, formatDate } from '@/utils/formatTime'
|
||||
import { MyProcessViewer } from '@/components/bpmnProcessDesigner/package'
|
||||
import draggable from 'vuedraggable'
|
||||
import { CategoryApi } from '@/api/bpm/category'
|
||||
import * as ModelApi from '@/api/bpm/model'
|
||||
import * as FormApi from '@/api/bpm/form'
|
||||
import ModelForm from './ModelForm.vue'
|
||||
import ModelImportForm from '@/views/bpm/model/ModelImportForm.vue'
|
||||
import { setConfAndFields2 } from '@/utils/formCreate'
|
||||
import CategoryForm from '../category/CategoryForm.vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import CategoryDraggableModel from './CategoryDraggableModel.vue'
|
||||
|
||||
defineOptions({ name: 'BpmModel' })
|
||||
|
||||
const { push } = useRouter()
|
||||
const message = useMessage() // 消息弹窗
|
||||
const { t } = useI18n() // 国际化
|
||||
const { push } = useRouter() // 路由
|
||||
|
||||
const loading = ref(true) // 列表的加载中
|
||||
const total = ref(0) // 列表的总页数
|
||||
const list = ref([]) // 列表的数据
|
||||
const isCategorySorting = ref(false) // 是否 category 正处于排序状态
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
key: undefined,
|
||||
name: undefined,
|
||||
category: undefined
|
||||
name: undefined
|
||||
})
|
||||
const queryFormRef = ref() // 搜索的表单
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await ModelApi.getModelPage(queryParams)
|
||||
list.value = data.list
|
||||
total.value = data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const categoryGroup: any = ref([]) // 按照 category 分组的数据
|
||||
const originalData: any = ref([]) // 原始数据
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
const handleQuery = () => {
|
||||
queryParams.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value.resetFields()
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const formRef = ref()
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value.open(type, id)
|
||||
}
|
||||
|
||||
/** 添加/修改操作 */
|
||||
const importFormRef = ref()
|
||||
const openImportForm = () => {
|
||||
importFormRef.value.open()
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.delConfirm()
|
||||
// 发起删除
|
||||
await ModelApi.deleteModel(id)
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 更新状态操作 */
|
||||
const handleChangeState = async (row) => {
|
||||
const state = row.processDefinition.suspensionState
|
||||
try {
|
||||
// 修改状态的二次确认
|
||||
const id = row.id
|
||||
const statusState = state === 1 ? '激活' : '挂起'
|
||||
const content = '是否确认' + statusState + '流程名字为"' + row.name + '"的数据项?'
|
||||
await message.confirm(content)
|
||||
// 发起修改状态
|
||||
await ModelApi.updateModelState(id, state)
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {
|
||||
// 取消后,进行恢复按钮
|
||||
row.processDefinition.suspensionState = state === 1 ? 2 : 1
|
||||
if (type === 'create') {
|
||||
push({ name: 'BpmModelCreate' })
|
||||
} else {
|
||||
push({
|
||||
name: 'BpmModelUpdate',
|
||||
params: { id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 设计流程 */
|
||||
const handleDesign = (row) => {
|
||||
push({
|
||||
name: 'BpmModelEditor',
|
||||
query: {
|
||||
modelId: row.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 发布流程 */
|
||||
const handleDeploy = async (row) => {
|
||||
try {
|
||||
// 删除的二次确认
|
||||
await message.confirm('是否部署该流程!!')
|
||||
// 发起部署
|
||||
await ModelApi.deployModel(row.id)
|
||||
message.success(t('部署成功'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 点击任务分配按钮 */
|
||||
const handleAssignRule = (row) => {
|
||||
push({
|
||||
name: 'BpmTaskAssignRuleList',
|
||||
query: {
|
||||
modelId: row.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 跳转到指定流程定义列表 */
|
||||
const handleDefinitionList = (row) => {
|
||||
push({
|
||||
name: 'BpmProcessDefinition',
|
||||
query: {
|
||||
key: row.key
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 流程表单的详情按钮操作 */
|
||||
const formDetailVisible = ref(false)
|
||||
const formDetailPreview = ref({
|
||||
rule: [],
|
||||
option: {}
|
||||
})
|
||||
const handleFormDetail = async (row) => {
|
||||
if (row.formType == 10) {
|
||||
// 设置表单
|
||||
const data = await FormApi.getForm(row.formId)
|
||||
setConfAndFields2(formDetailPreview, data.conf, data.fields)
|
||||
// 弹窗打开
|
||||
formDetailVisible.value = true
|
||||
} else {
|
||||
await push({
|
||||
path: row.formCustomCreatePath
|
||||
})
|
||||
|
||||
/** 右上角设置按钮 */
|
||||
const handleCommand = (command: string) => {
|
||||
switch (command) {
|
||||
case 'handleCategoryAdd':
|
||||
handleCategoryAdd()
|
||||
break
|
||||
case 'handleCategorySort':
|
||||
handleCategorySort()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 流程图的详情按钮操作 */
|
||||
const bpmnDetailVisible = ref(false)
|
||||
const bpmnXML = ref(null)
|
||||
const bpmnControlForm = ref({
|
||||
prefix: 'flowable'
|
||||
})
|
||||
const handleBpmnDetail = async (row) => {
|
||||
const data = await ModelApi.getModel(row.id)
|
||||
bpmnXML.value = data.bpmnXml || ''
|
||||
bpmnDetailVisible.value = true
|
||||
/** 新建分类 */
|
||||
const categoryFormRef = ref()
|
||||
const handleCategoryAdd = () => {
|
||||
categoryFormRef.value.open('create')
|
||||
}
|
||||
|
||||
/** 分类排序的提交 */
|
||||
const handleCategorySort = () => {
|
||||
// 保存初始数据
|
||||
originalData.value = cloneDeep(categoryGroup.value)
|
||||
isCategorySorting.value = true
|
||||
}
|
||||
|
||||
/** 分类排序的取消 */
|
||||
const handleCategorySortCancel = () => {
|
||||
// 恢复初始数据
|
||||
categoryGroup.value = cloneDeep(originalData.value)
|
||||
isCategorySorting.value = false
|
||||
}
|
||||
|
||||
/** 分类排序的保存 */
|
||||
const handleCategorySortSubmit = async () => {
|
||||
// 保存排序
|
||||
const ids = categoryGroup.value.map((item: any) => item.id)
|
||||
await CategoryApi.updateCategorySortBatch(ids)
|
||||
// 刷新列表
|
||||
isCategorySorting.value = false
|
||||
message.success('排序分类成功')
|
||||
await getList()
|
||||
}
|
||||
|
||||
/** 加载数据 */
|
||||
const getList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// 查询模型 + 分裂的列表
|
||||
const modelList = await ModelApi.getModelList(queryParams.name)
|
||||
const categoryList = await CategoryApi.getCategorySimpleList()
|
||||
// 按照 category 聚合
|
||||
// 注意:必须一次性赋值给 categoryGroup,否则每次操作后,列表会重新渲染,滚动条的位置会偏离!!!
|
||||
categoryGroup.value = categoryList.map((category: any) => ({
|
||||
...category,
|
||||
modelList: modelList.filter((model: any) => model.categoryName == category.name)
|
||||
}))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
onMounted(() => {
|
||||
onActivated(() => {
|
||||
getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep() {
|
||||
.el-table--fit .el-table__inner-wrapper:before {
|
||||
height: 0;
|
||||
}
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.el-form--inline .el-form-item {
|
||||
margin-right: 10px;
|
||||
}
|
||||
.el-divider--horizontal {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user