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:
puhui999
2025-06-06 18:41:39 +08:00
1145 changed files with 123107 additions and 13043 deletions

View File

@@ -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/21Vue 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>

View File

@@ -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 = {

View File

@@ -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>

View File

@@ -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()
}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) }}

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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()
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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">
<!-- 靠左 messagesystemassistant 类型 -->
<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>
<!-- 靠右 messageuser 类型 -->
<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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,104 @@
<template>
<div class="square-container">
<!-- TODO @fanstyle 建议换成 unocss -->
<!-- TODO @fanSearch 可以换成 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 @fantitle 是不是收敛到 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>

View 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>

View 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>

View 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>

View File

@@ -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) // 12
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()
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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- 虚拟 DOMVue.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 的生态系统还包括大量的第三方库和插件,如 VuetifyUI 组件库、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`

View 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)
}

View 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>

View 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>

View 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>

View 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>

View 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 里边定义可复用的组件DefineLabelReuseLabel 是采用的解构赋值,都是 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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()

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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">
提示仅允许导入bpmxml格式文件
</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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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