Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/bpm
5
.env
|
@ -18,3 +18,8 @@ VITE_APP_DOCALERT_ENABLE=true
|
||||||
|
|
||||||
# 百度统计
|
# 百度统计
|
||||||
VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc
|
VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc
|
||||||
|
|
||||||
|
# 默认账户密码
|
||||||
|
VITE_APP_DEFAULT_LOGIN_TENANT = 芋道源码
|
||||||
|
VITE_APP_DEFAULT_LOGIN_USERNAME = admin
|
||||||
|
VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123
|
||||||
|
|
|
@ -2,7 +2,6 @@ node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
|
||||||
/dist*
|
/dist*
|
||||||
pnpm-debug
|
pnpm-debug
|
||||||
auto-*.d.ts
|
auto-*.d.ts
|
||||||
|
|
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 348 KiB |
35
README.md
|
@ -54,17 +54,16 @@
|
||||||
|
|
||||||
推荐 VS Code 开发,配合插件如下:
|
推荐 VS Code 开发,配合插件如下:
|
||||||
|
|
||||||
| 插件名 | 功能 |
|
| 插件名 | 功能 |
|
||||||
|-------------------------------|--------------------------|
|
|-------------------------------|---------------------|
|
||||||
| TypeScript Vue Plugin (Volar) | 用于 TypeScript 的 Vue 插件 |
|
| Vue - Official | Vue 与 TypeScript 支持 |
|
||||||
| Vue Language Features (Volar) | Vue3.0 语法支持 |
|
| unocss | unocss for vscode |
|
||||||
| unocss | unocss for vscode |
|
| Iconify IntelliSense | Iconify 预览和搜索 |
|
||||||
| Iconify IntelliSense | Iconify 预览和搜索 |
|
| i18n Ally | 国际化智能提示 |
|
||||||
| i18n Ally | 国际化智能提示 |
|
| Stylelint | Css 格式化 |
|
||||||
| Stylelint | Css 格式化 |
|
| Prettier | 代码格式化 |
|
||||||
| Prettier | 代码格式化 |
|
| ESLint | 脚本代码检查 |
|
||||||
| ESLint | 脚本代码检查 |
|
| DotENV | env 文件高亮 |
|
||||||
| DotENV | env 文件高亮 |
|
|
||||||
|
|
||||||
## 🔥 后端架构
|
## 🔥 后端架构
|
||||||
|
|
||||||
|
@ -192,26 +191,24 @@ ps:核心功能已经实现,正在对接微信小程序中...
|
||||||
|
|
||||||
### 商城系统
|
### 商城系统
|
||||||
|
|
||||||
|
演示地址:<https://doc.iocoder.cn/mall-preview/>
|
||||||
|
|
||||||
![功能图](/.image/common/mall-feature.png)
|
![功能图](/.image/common/mall-feature.png)
|
||||||
|
|
||||||
![功能图](/.image/common/mall-preview.png)
|
![功能图](/.image/common/mall-preview.png)
|
||||||
|
|
||||||
_前端基于 crmeb uniapp 经过授权重构,优化代码实现,接入芋道快速开发平台_
|
|
||||||
|
|
||||||
演示地址:<https://doc.iocoder.cn/mall-preview/>
|
|
||||||
|
|
||||||
### ERP 系统
|
### ERP 系统
|
||||||
|
|
||||||
![功能图](/.image/common/erp-feature.png)
|
|
||||||
|
|
||||||
演示地址:<https://doc.iocoder.cn/erp-preview/>
|
演示地址:<https://doc.iocoder.cn/erp-preview/>
|
||||||
|
|
||||||
|
![功能图](/.image/common/erp-feature.png)
|
||||||
|
|
||||||
### CRM 系统
|
### CRM 系统
|
||||||
|
|
||||||
![功能图](/.image/common/crm-feature.png)
|
|
||||||
|
|
||||||
演示地址:<https://doc.iocoder.cn/crm-preview/>
|
演示地址:<https://doc.iocoder.cn/crm-preview/>
|
||||||
|
|
||||||
|
![功能图](/.image/common/crm-feature.png)
|
||||||
|
|
||||||
## 🐷 演示图
|
## 🐷 演示图
|
||||||
|
|
||||||
### 系统功能
|
### 系统功能
|
||||||
|
|
|
@ -27,6 +27,12 @@ const include = [
|
||||||
'echarts-wordcloud',
|
'echarts-wordcloud',
|
||||||
'@wangeditor/editor',
|
'@wangeditor/editor',
|
||||||
'@wangeditor/editor-for-vue',
|
'@wangeditor/editor-for-vue',
|
||||||
|
'@microsoft/fetch-event-source',
|
||||||
|
'markdown-it',
|
||||||
|
'markmap-view',
|
||||||
|
'markmap-lib',
|
||||||
|
'markmap-toolbar',
|
||||||
|
'highlight.js',
|
||||||
'element-plus',
|
'element-plus',
|
||||||
'element-plus/es',
|
'element-plus/es',
|
||||||
'element-plus/es/locale/lang/zh-cn',
|
'element-plus/es/locale/lang/zh-cn',
|
||||||
|
@ -104,7 +110,11 @@ const include = [
|
||||||
'element-plus/es/components/collapse/style/css',
|
'element-plus/es/components/collapse/style/css',
|
||||||
'element-plus/es/components/collapse-item/style/css',
|
'element-plus/es/components/collapse-item/style/css',
|
||||||
'element-plus/es/components/button-group/style/css',
|
'element-plus/es/components/button-group/style/css',
|
||||||
'element-plus/es/components/text/style/css'
|
'element-plus/es/components/text/style/css',
|
||||||
|
'element-plus/es/components/segmented/style/css',
|
||||||
|
'@element-plus/icons-vue',
|
||||||
|
'element-plus/es/components/footer/style/css',
|
||||||
|
'element-plus/es/components/empty/style/css'
|
||||||
]
|
]
|
||||||
|
|
||||||
const exclude = ['@iconify/json']
|
const exclude = ['@iconify/json']
|
||||||
|
|
10
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "yudao-ui-admin-vue3",
|
"name": "yudao-ui-admin-vue3",
|
||||||
"version": "2.1.0-snapshot",
|
"version": "2.2.0-snapshot",
|
||||||
"description": "基于vue3、vite4、element-plus、typesScript",
|
"description": "基于vue3、vite4、element-plus、typesScript",
|
||||||
"author": "xingyu",
|
"author": "xingyu",
|
||||||
"private": false,
|
"private": false,
|
||||||
|
@ -52,7 +52,11 @@
|
||||||
"highlight.js": "^11.9.0",
|
"highlight.js": "^11.9.0",
|
||||||
"jsencrypt": "^3.3.2",
|
"jsencrypt": "^3.3.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^12.0.2",
|
"markdown-it": "^14.1.0",
|
||||||
|
"markmap-common": "^0.16.0",
|
||||||
|
"markmap-lib": "^0.16.1",
|
||||||
|
"markmap-toolbar": "^0.17.0",
|
||||||
|
"markmap-view": "^0.16.0",
|
||||||
"min-dash": "^4.1.1",
|
"min-dash": "^4.1.1",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
@ -85,8 +89,8 @@
|
||||||
"@types/qs": "^6.9.12",
|
"@types/qs": "^6.9.12",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
"@typescript-eslint/parser": "^7.1.0",
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
"@unocss/transformer-variant-group": "^0.58.5",
|
|
||||||
"@unocss/eslint-config": "^0.57.4",
|
"@unocss/eslint-config": "^0.57.4",
|
||||||
|
"@unocss/transformer-variant-group": "^0.58.5",
|
||||||
"@vitejs/plugin-legacy": "^5.3.1",
|
"@vitejs/plugin-legacy": "^5.3.1",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||||
|
|
12574
pnpm-lock.yaml
|
@ -2,7 +2,7 @@ import request from '@/config/axios'
|
||||||
|
|
||||||
// AI 聊天对话 VO
|
// AI 聊天对话 VO
|
||||||
export interface ChatConversationVO {
|
export interface ChatConversationVO {
|
||||||
id: string // ID 编号
|
id: number // ID 编号
|
||||||
userId: number // 用户编号
|
userId: number // 用户编号
|
||||||
title: string // 对话标题
|
title: string // 对话标题
|
||||||
pinned: boolean // 是否置顶
|
pinned: boolean // 是否置顶
|
||||||
|
@ -12,6 +12,7 @@ export interface ChatConversationVO {
|
||||||
temperature: number // 温度参数
|
temperature: number // 温度参数
|
||||||
maxTokens: number // 单条回复的最大 Token 数量
|
maxTokens: number // 单条回复的最大 Token 数量
|
||||||
maxContexts: number // 上下文的最大 Message 数量
|
maxContexts: number // 上下文的最大 Message 数量
|
||||||
|
createTime?: Date // 创建时间
|
||||||
// 额外字段
|
// 额外字段
|
||||||
systemMessage?: string // 角色设定
|
systemMessage?: string // 角色设定
|
||||||
modelName?: string // 模型名字
|
modelName?: string // 模型名字
|
||||||
|
@ -23,7 +24,7 @@ export interface ChatConversationVO {
|
||||||
// AI 聊天对话 API
|
// AI 聊天对话 API
|
||||||
export const ChatConversationApi = {
|
export const ChatConversationApi = {
|
||||||
// 获得【我的】聊天对话
|
// 获得【我的】聊天对话
|
||||||
getChatConversationMy: async (id: string) => {
|
getChatConversationMy: async (id: number) => {
|
||||||
return await request.get({ url: `/ai/chat/conversation/get-my?id=${id}` })
|
return await request.get({ url: `/ai/chat/conversation/get-my?id=${id}` })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -43,8 +44,8 @@ export const ChatConversationApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除【我的】所有对话,置顶除外
|
// 删除【我的】所有对话,置顶除外
|
||||||
deleteMyAllExceptPinned: async () => {
|
deleteChatConversationMyByUnpinned: async () => {
|
||||||
return await request.delete({ url: `/ai/chat/conversation/delete-my-all-except-pinned` })
|
return await request.delete({ url: `/ai/chat/conversation/delete-by-unpinned` })
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获得【我的】聊天对话列表
|
// 获得【我的】聊天对话列表
|
||||||
|
|
|
@ -19,23 +19,18 @@ export interface ChatMessageVO {
|
||||||
userAvatar: string // 创建时间
|
userAvatar: string // 创建时间
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageSendVO {
|
|
||||||
conversationId: string // 对话编号
|
|
||||||
content: number // 聊天内容
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI chat 聊天
|
// AI chat 聊天
|
||||||
export const ChatMessageApi = {
|
export const ChatMessageApi = {
|
||||||
// 消息列表
|
// 消息列表
|
||||||
messageList: async (conversationId: string | null) => {
|
getChatMessageListByConversationId: async (conversationId: number | null) => {
|
||||||
return await request.get({
|
return await request.get({
|
||||||
url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`
|
url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// 发送 send stream 消息
|
// 发送 Stream 消息
|
||||||
// TODO axios 可以么? https://apifox.com/apiskills/how-to-create-axios-stream/
|
// 为什么不用 axios 呢?因为它不支持 SSE 调用
|
||||||
sendStream: async (
|
sendChatMessageStream: async (
|
||||||
conversationId: number,
|
conversationId: number,
|
||||||
content: string,
|
content: string,
|
||||||
ctrl,
|
ctrl,
|
||||||
|
@ -65,12 +60,12 @@ export const ChatMessageApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除消息
|
// 删除消息
|
||||||
delete: async (id: string) => {
|
deleteChatMessage: async (id: string) => {
|
||||||
return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
|
return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除消息 - 对话所有消息
|
// 删除指定对话的消息
|
||||||
deleteByConversationId: async (conversationId: string) => {
|
deleteByConversationId: async (conversationId: number) => {
|
||||||
return await request.delete({
|
return await request.delete({
|
||||||
url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`
|
url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,49 +1,22 @@
|
||||||
import request from '@/config/axios'
|
import request from '@/config/axios'
|
||||||
|
|
||||||
// AI API 密钥 VO
|
// AI 绘图 VO
|
||||||
// TODO @fan:要不前端不弄太多 VO,就用这个 ImageDetailVO?!
|
export interface ImageVO {
|
||||||
export interface ImageDetailVO {
|
|
||||||
id: number // 编号
|
id: number // 编号
|
||||||
prompt: string // 提示词
|
|
||||||
status: number // 状态
|
|
||||||
errorMessage: string // 错误信息
|
|
||||||
type: string // 模型下分不同的类型(清晰、真实...)
|
|
||||||
taskId: number // dr 任务id
|
|
||||||
picUrl: string // 任务地址
|
|
||||||
originalPicUrl: string // 绘制图片地址
|
|
||||||
platform: string // 平台
|
platform: string // 平台
|
||||||
model: string // 模型
|
model: string // 模型
|
||||||
style: string // 图像生成的风格
|
|
||||||
size: string // 图片尺寸
|
|
||||||
buttons: ImageMjButtonsVO[] // mj 操作按钮
|
|
||||||
createTime: string // 创建时间
|
|
||||||
updateTime: string // 更新事件
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageMjButtonsVO {
|
|
||||||
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
|
|
||||||
emoji: string // 图标 emoji
|
|
||||||
label: string // Make Variations 文本
|
|
||||||
style: number // 样式: 2(Primary)、3(Green)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageMjActionVO {
|
|
||||||
id: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
|
|
||||||
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface ImagePageReqVO {
|
|
||||||
pageNo: number // 分页编号
|
|
||||||
pageSize: number // 分页大小
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageDallReqVO {
|
|
||||||
prompt: string // 提示词
|
prompt: string // 提示词
|
||||||
model: string // 模型
|
width: number // 图片宽度
|
||||||
style: string // 图像生成的风格
|
height: number // 图片高度
|
||||||
width: string // 图片宽度
|
status: number // 状态
|
||||||
height: string // 图片高度
|
publicStatus: boolean // 公开状态
|
||||||
|
picUrl: string // 任务地址
|
||||||
|
errorMessage: string // 错误信息
|
||||||
|
options: any // 配置 Map<string, string>
|
||||||
|
taskId: number // 任务编号
|
||||||
|
buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮
|
||||||
|
createTime: Date // 创建时间
|
||||||
|
finishTime: Date // 完成时间
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageDrawReqVO {
|
export interface ImageDrawReqVO {
|
||||||
|
@ -65,34 +38,66 @@ export interface ImageMidjourneyImagineReqVO {
|
||||||
version: string // 版本
|
version: string // 版本
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO 芋艿:review 下整体注释、方法名
|
export interface ImageMidjourneyActionVO {
|
||||||
// AI API 密钥 API
|
id: number // 图片编号
|
||||||
|
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageMidjourneyButtonsVO {
|
||||||
|
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
|
||||||
|
emoji: string // 图标 emoji
|
||||||
|
label: string // Make Variations 文本
|
||||||
|
style: number // 样式: 2(Primary)、3(Green)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 图片 API
|
||||||
export const ImageApi = {
|
export const ImageApi = {
|
||||||
// 获取 image 列表
|
// 获取【我的】绘图分页
|
||||||
getImageList: async (params: ImagePageReqVO) => {
|
getImagePageMy: async (params: any) => {
|
||||||
return await request.get({ url: `/ai/image/my-page`, params })
|
return await request.get({ url: `/ai/image/my-page`, params })
|
||||||
},
|
},
|
||||||
// 获取 image 详细信息
|
// 获取【我的】绘图记录
|
||||||
getImageDetail: async (id: number) => {
|
getImageMy: async (id: number) => {
|
||||||
return await request.get({ url: `/ai/image/get-my?id=${id}`})
|
return await request.get({ url: `/ai/image/get-my?id=${id}` })
|
||||||
|
},
|
||||||
|
// 获取【我的】绘图记录列表
|
||||||
|
getImageListMyByIds: async (ids: number[]) => {
|
||||||
|
return await request.get({ url: `/ai/image/my-list-by-ids`, params: { ids: ids.join(',') } })
|
||||||
},
|
},
|
||||||
// 生成图片
|
// 生成图片
|
||||||
drawImage: async (data: ImageDrawReqVO)=> {
|
drawImage: async (data: ImageDrawReqVO) => {
|
||||||
return await request.post({ url: `/ai/image/draw`, data })
|
return await request.post({ url: `/ai/image/draw`, data })
|
||||||
},
|
},
|
||||||
// 删除
|
// 删除【我的】绘画记录
|
||||||
deleteImage: async (id: number)=> {
|
deleteImageMy: async (id: number) => {
|
||||||
return await request.delete({ url: `/ai/image/delete-my?id=${id}`})
|
return await request.delete({ url: `/ai/image/delete-my?id=${id}` })
|
||||||
},
|
},
|
||||||
|
|
||||||
// ------------ midjourney
|
// ================ midjourney 专属 ================
|
||||||
|
|
||||||
// midjourney - imagine
|
// 【Midjourney】生成图片
|
||||||
midjourneyImagine: async (data: ImageMidjourneyImagineReqVO)=> {
|
midjourneyImagine: async (data: ImageMidjourneyImagineReqVO) => {
|
||||||
return await request.post({ url: `/ai/image/midjourney/imagine`, data })
|
return await request.post({ url: `/ai/image/midjourney/imagine`, data })
|
||||||
},
|
},
|
||||||
// midjourney - action
|
// 【Midjourney】Action 操作(二次生成图片)
|
||||||
midjourneyAction: async (params: ImageMjActionVO)=> {
|
midjourneyAction: async (data: ImageMidjourneyActionVO) => {
|
||||||
return await request.get({ url: `/ai/image/midjourney/action`, params })
|
return await request.post({ url: `/ai/image/midjourney/action`, data })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ================ 绘图管理 ================
|
||||||
|
|
||||||
|
// 查询绘画分页
|
||||||
|
getImagePage: async (params: any) => {
|
||||||
|
return await request.get({ url: `/ai/image/page`, params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新绘画发布状态
|
||||||
|
updateImage: async (data: any) => {
|
||||||
|
return await request.put({ url: '/ai/image/update', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除绘画
|
||||||
|
deleteImage: async (id: number) => {
|
||||||
|
return await request.delete({ url: `/ai/image/delete?id=` + id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { getAccessToken } from '@/utils/auth'
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||||
|
import { config } from '@/config/axios/config'
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
// AI 思维导图 VO
|
||||||
|
export interface MindMapVO {
|
||||||
|
id: number // 编号
|
||||||
|
userId: number // 用户编号
|
||||||
|
prompt: string // 生成内容提示
|
||||||
|
generatedContent: string // 生成的思维导图内容
|
||||||
|
platform: string // 平台
|
||||||
|
model: string // 模型
|
||||||
|
errorMessage: string // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 思维导图生成 VO
|
||||||
|
export interface AiMindMapGenerateReqVO {
|
||||||
|
prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiMindMapApi = {
|
||||||
|
generateMindMap: ({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onMessage,
|
||||||
|
onError,
|
||||||
|
ctrl
|
||||||
|
}: {
|
||||||
|
data: AiMindMapGenerateReqVO
|
||||||
|
onMessage?: (res: any) => void
|
||||||
|
onError?: (...args: any[]) => void
|
||||||
|
onClose?: (...args: any[]) => void
|
||||||
|
ctrl: AbortController
|
||||||
|
}) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
return fetchEventSource(`${config.base_url}/ai/mind-map/generate-stream`, {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
onmessage: onMessage,
|
||||||
|
onerror: onError,
|
||||||
|
onclose: onClose,
|
||||||
|
signal: ctrl.signal
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 查询思维导图分页
|
||||||
|
getMindMapPage: async (params: any) => {
|
||||||
|
return await request.get({ url: `/ai/mind-map/page`, params })
|
||||||
|
},
|
||||||
|
// 删除思维导图
|
||||||
|
deleteMindMap: async (id: number) => {
|
||||||
|
return await request.delete({ url: `/ai/mind-map/delete?id=` + id })
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
// AI 音乐 VO
|
||||||
|
export interface MusicVO {
|
||||||
|
id: number // 编号
|
||||||
|
userId: number // 用户编号
|
||||||
|
title: string // 音乐名称
|
||||||
|
lyric: string // 歌词
|
||||||
|
imageUrl: string // 图片地址
|
||||||
|
audioUrl: string // 音频地址
|
||||||
|
videoUrl: string // 视频地址
|
||||||
|
status: number // 音乐状态
|
||||||
|
gptDescriptionPrompt: string // 描述词
|
||||||
|
prompt: string // 提示词
|
||||||
|
platform: string // 模型平台
|
||||||
|
model: string // 模型
|
||||||
|
generateMode: number // 生成模式
|
||||||
|
tags: string // 音乐风格标签
|
||||||
|
duration: number // 音乐时长
|
||||||
|
publicStatus: boolean // 是否发布
|
||||||
|
taskId: string // 任务id
|
||||||
|
errorMessage: string // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 音乐 API
|
||||||
|
export const MusicApi = {
|
||||||
|
// 查询音乐分页
|
||||||
|
getMusicPage: async (params: any) => {
|
||||||
|
return await request.get({ url: `/ai/music/page`, params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新音乐
|
||||||
|
updateMusic: async (data: any) => {
|
||||||
|
return await request.put({ url: '/ai/music/update', data })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除音乐
|
||||||
|
deleteMusic: async (id: number) => {
|
||||||
|
return await request.delete({ url: `/ai/music/delete?id=` + id })
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||||
|
|
||||||
|
import { getAccessToken } from '@/utils/auth'
|
||||||
|
import { config } from '@/config/axios/config'
|
||||||
|
import { AiWriteTypeEnum } from '@/views/ai/utils/constants'
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
export interface WriteVO {
|
||||||
|
type: AiWriteTypeEnum.WRITING | AiWriteTypeEnum.REPLY // 1:撰写 2:回复
|
||||||
|
prompt: string // 写作内容提示 1。撰写 2回复
|
||||||
|
originalContent: string // 原文
|
||||||
|
length: number // 长度
|
||||||
|
format: number // 格式
|
||||||
|
tone: number // 语气
|
||||||
|
language: number // 语言
|
||||||
|
userId?: number // 用户编号
|
||||||
|
platform?: string // 平台
|
||||||
|
model?: string // 模型
|
||||||
|
generatedContent?: string // 生成的内容
|
||||||
|
errorMessage?: string // 错误信息
|
||||||
|
createTime?: Date // 创建时间
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiWritePageReqVO extends PageParam {
|
||||||
|
userId?: number // 用户编号
|
||||||
|
type?: AiWriteTypeEnum // 写作类型
|
||||||
|
platform?: string // 平台
|
||||||
|
createTime?: [string, string] // 创建时间
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiWriteRespVo {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
type: number
|
||||||
|
platform: string
|
||||||
|
model: string
|
||||||
|
prompt: string
|
||||||
|
generatedContent: string
|
||||||
|
originalContent: string
|
||||||
|
length: number
|
||||||
|
format: number
|
||||||
|
tone: number
|
||||||
|
language: number
|
||||||
|
errorMessage: string
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WriteApi = {
|
||||||
|
writeStream: ({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onMessage,
|
||||||
|
onError,
|
||||||
|
ctrl
|
||||||
|
}: {
|
||||||
|
data: WriteVO
|
||||||
|
onMessage?: (res: any) => void
|
||||||
|
onError?: (...args: any[]) => void
|
||||||
|
onClose?: (...args: any[]) => void
|
||||||
|
ctrl: AbortController
|
||||||
|
}) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
return fetchEventSource(`${config.base_url}/ai/write/generate-stream`, {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
openWhenHidden: true,
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
onmessage: onMessage,
|
||||||
|
onerror: onError,
|
||||||
|
onclose: onClose,
|
||||||
|
signal: ctrl.signal
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 获取写作列表
|
||||||
|
getWritePage: (params: AiWritePageReqVO) => {
|
||||||
|
return request.get<PageResult<AiWriteRespVo[]>>({ url: `/ai/write/page`, params })
|
||||||
|
},
|
||||||
|
// 删除写作
|
||||||
|
deleteWrite(id: number) {
|
||||||
|
return request.delete({ url: `/ai/write/delete`, params: { id } })
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import request from '@/config/axios'
|
import request from '@/config/axios'
|
||||||
|
|
||||||
export const getProcessDefinition = async (id: number, key: string) => {
|
export const getProcessDefinition = async (id?: string, key?: string) => {
|
||||||
return await request.get({
|
return await request.get({
|
||||||
url: '/bpm/process-definition/get',
|
url: '/bpm/process-definition/get',
|
||||||
params: { id, key }
|
params: { id, key }
|
||||||
|
|
|
@ -5,6 +5,7 @@ export type ProcessDefinitionVO = {
|
||||||
version: number
|
version: number
|
||||||
deploymentTIme: string
|
deploymentTIme: string
|
||||||
suspensionState: number
|
suspensionState: number
|
||||||
|
formType?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelVO = {
|
export type ModelVO = {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import request from '@/config/axios'
|
import request from '@/config/axios'
|
||||||
|
import { ProcessDefinitionVO } from '@/api/bpm/model'
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
id: string
|
id: string
|
||||||
|
@ -18,17 +19,7 @@ export type ProcessInstanceVO = {
|
||||||
businessKey: string
|
businessKey: string
|
||||||
createTime: string
|
createTime: string
|
||||||
endTime: string
|
endTime: string
|
||||||
}
|
processDefinition?: ProcessDefinitionVO
|
||||||
|
|
||||||
export type ProcessInstanceCopyVO = {
|
|
||||||
type: number
|
|
||||||
taskName: string
|
|
||||||
taskKey: string
|
|
||||||
processInstanceName: string
|
|
||||||
processInstanceKey: string
|
|
||||||
startUserId: string
|
|
||||||
options: string[]
|
|
||||||
reason: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProcessInstanceMyPage = async (params: any) => {
|
export const getProcessInstanceMyPage = async (params: any) => {
|
||||||
|
|
|
@ -12,6 +12,7 @@ export interface JobLogVO {
|
||||||
duration: string
|
duration: string
|
||||||
status: number
|
status: number
|
||||||
createTime: string
|
createTime: string
|
||||||
|
result: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 任务日志列表
|
// 任务日志列表
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获得商品浏览记录分页
|
||||||
|
*
|
||||||
|
* @param params 请求参数
|
||||||
|
*/
|
||||||
|
export const getBrowseHistoryPage = (params: any) => {
|
||||||
|
return request.get({ url: '/product/browse-history/page', params })
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
export interface KeFuConversationRespVO {
|
||||||
|
id: number // 编号
|
||||||
|
userId: number // 会话所属用户
|
||||||
|
userAvatar: string // 会话所属用户头像
|
||||||
|
userNickname: string // 会话所属用户昵称
|
||||||
|
lastMessageTime: Date // 最后聊天时间
|
||||||
|
lastMessageContent: string // 最后聊天内容
|
||||||
|
lastMessageContentType: number // 最后发送的消息类型
|
||||||
|
adminPinned: boolean // 管理端置顶
|
||||||
|
userDeleted: boolean // 用户是否可见
|
||||||
|
adminDeleted: boolean // 管理员是否可见
|
||||||
|
adminUnreadMessageCount: number // 管理员未读消息数
|
||||||
|
createTime?: string // 创建时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客服会话 API
|
||||||
|
export const KeFuConversationApi = {
|
||||||
|
// 获得客服会话列表
|
||||||
|
getConversationList: async () => {
|
||||||
|
return await request.get({ url: '/promotion/kefu-conversation/list' })
|
||||||
|
},
|
||||||
|
// 客服会话置顶
|
||||||
|
updateConversationPinned: async (data: any) => {
|
||||||
|
return await request.put({
|
||||||
|
url: '/promotion/kefu-conversation/update-conversation-pinned',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 删除客服会话
|
||||||
|
deleteConversation: async (id: number) => {
|
||||||
|
return await request.get({ url: '/promotion/kefu-conversation/delete?id' + id })
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import request from '@/config/axios'
|
||||||
|
|
||||||
|
export interface KeFuMessageRespVO {
|
||||||
|
id: number // 编号
|
||||||
|
conversationId: number // 会话编号
|
||||||
|
senderId: number // 发送人编号
|
||||||
|
senderAvatar: string // 发送人头像
|
||||||
|
senderType: number // 发送人类型
|
||||||
|
receiverId: number // 接收人编号
|
||||||
|
receiverType: number // 接收人类型
|
||||||
|
contentType: number // 消息类型
|
||||||
|
content: string // 消息
|
||||||
|
readStatus: boolean // 是否已读
|
||||||
|
createTime: Date // 创建时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客服会话 API
|
||||||
|
export const KeFuMessageApi = {
|
||||||
|
// 发送客服消息
|
||||||
|
sendKeFuMessage: async (data: any) => {
|
||||||
|
return await request.post({
|
||||||
|
url: '/promotion/kefu-message/send',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 更新客服消息已读状态
|
||||||
|
updateKeFuMessageReadStatus: async (conversationId: number) => {
|
||||||
|
return await request.put({
|
||||||
|
url: '/promotion/kefu-message/update-read-status?conversationId=' + conversationId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 获得消息分页数据
|
||||||
|
getKeFuMessagePage: async (params: any) => {
|
||||||
|
return await request.get({ url: '/promotion/kefu-message/page', params })
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1716342375293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2604" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M899.1 869.6l-53-305.6H864c14.4 0 26-11.6 26-26V346c0-14.4-11.6-26-26-26H618V138c0-14.4-11.6-26-26-26H432c-14.4 0-26 11.6-26 26v182H160c-14.4 0-26 11.6-26 26v192c0 14.4 11.6 26 26 26h17.9l-53 305.6c-0.3 1.5-0.4 3-0.4 4.4 0 14.4 11.6 26 26 26h723c1.5 0 3-0.1 4.4-0.4 14.2-2.4 23.7-15.9 21.2-30zM204 390h272V182h72v208h272v104H204V390z m468 440V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H416V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H202.8l45.1-260H776l45.1 260H672z" p-id="2605" fill="#8a8a8a"></path></svg>
|
|
Before Width: | Height: | Size: 844 B |
After Width: | Height: | Size: 108 KiB |
After Width: | Height: | Size: 87 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 121 KiB |
|
@ -54,7 +54,7 @@ const currentLocale = computed(() => localeStore.currentLocale)
|
||||||
<ElConfigProvider
|
<ElConfigProvider
|
||||||
:namespace="variables.elNamespace"
|
:namespace="variables.elNamespace"
|
||||||
:locale="currentLocale.elLocale"
|
:locale="currentLocale.elLocale"
|
||||||
:message="{ max: 1 }"
|
:message="{ max: 5 }"
|
||||||
:size="size"
|
:size="size"
|
||||||
>
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|
|
@ -10,12 +10,13 @@ const prefixCls = getPrefixCls('content-wrap')
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
title: propTypes.string.def(''),
|
title: propTypes.string.def(''),
|
||||||
message: propTypes.string.def('')
|
message: propTypes.string.def(''),
|
||||||
|
bodyStyle: propTypes.object.def({ padding: '20px' })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElCard :class="[prefixCls, 'mb-15px']" shadow="never">
|
<ElCard :body-style="bodyStyle" :class="[prefixCls, 'mb-15px']" shadow="never">
|
||||||
<template v-if="title" #header>
|
<template v-if="title" #header>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="text-16px font-700">{{ title }}</span>
|
<span class="text-16px font-700">{{ title }}</span>
|
||||||
|
@ -30,8 +31,6 @@ defineProps({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<slot></slot>
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</ElCard>
|
</ElCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
<script lang="tsx">
|
<script lang="tsx">
|
||||||
import { defineComponent, PropType, ref } from 'vue'
|
import { defineComponent, PropType, computed } from 'vue'
|
||||||
import { isHexColor } from '@/utils/color'
|
import { isHexColor } from '@/utils/color'
|
||||||
import { ElTag } from 'element-plus'
|
import { ElTag } from 'element-plus'
|
||||||
import { DictDataType, getDictOptions } from '@/utils/dict'
|
import { DictDataType, getDictOptions } from '@/utils/dict'
|
||||||
|
import { isArray, isString, isNumber } from '@/utils/is'
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'DictTag',
|
name: 'DictTag',
|
||||||
|
@ -12,49 +13,78 @@ export default defineComponent({
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
value: {
|
value: {
|
||||||
type: [String, Number, Boolean] as PropType<string | number | boolean>,
|
type: [String, Number, Boolean, Array],
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
// 字符串分隔符 只有当 props.value 传入值为字符串时有效
|
||||||
|
separator: {
|
||||||
|
type: String as PropType<string>,
|
||||||
|
default: ','
|
||||||
|
},
|
||||||
|
// 每个 tag 之间的间隔,默认为 5px,参考的 el-row 的 gutter
|
||||||
|
gutter: {
|
||||||
|
type: String as PropType<string>,
|
||||||
|
default: '5px'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const dictData = ref<DictDataType>()
|
const valueArr: any = computed(() => {
|
||||||
const getDictObj = (dictType: string, value: string) => {
|
// 1.是Number类型的情况
|
||||||
const dictOptions = getDictOptions(dictType)
|
if (isNumber(props.value)) {
|
||||||
dictOptions.forEach((dict: DictDataType) => {
|
return [String(props.value)]
|
||||||
if (dict.value === value) {
|
}
|
||||||
if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') {
|
// 2.是字符串(进一步判断是否有包含分隔符号 -> props.sepSymbol )
|
||||||
dict.colorType = ''
|
else if (isString(props.value)) {
|
||||||
}
|
return props.value.split(props.separator)
|
||||||
dictData.value = dict
|
}
|
||||||
}
|
// 3.数组
|
||||||
})
|
else if (isArray(props.value)) {
|
||||||
}
|
return props.value.map(String)
|
||||||
const rederDictTag = () => {
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
const renderDictTag = () => {
|
||||||
if (!props.type) {
|
if (!props.type) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// 解决自定义字典标签值为零时标签不渲染的问题
|
// 解决自定义字典标签值为零时标签不渲染的问题
|
||||||
if (props.value === undefined || props.value === null) {
|
if (props.value === undefined || props.value === null || props.value === '') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
getDictObj(props.type, props.value.toString())
|
const dictOptions = getDictOptions(props.type)
|
||||||
// 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题
|
|
||||||
return (
|
return (
|
||||||
<ElTag
|
<div
|
||||||
style={dictData.value?.cssClass ? 'color: #fff' : ''}
|
class="dict-tag"
|
||||||
type={dictData.value?.colorType}
|
style={{
|
||||||
color={
|
display: 'flex',
|
||||||
dictData.value?.cssClass && isHexColor(dictData.value?.cssClass)
|
gap: props.gutter,
|
||||||
? dictData.value?.cssClass
|
justifyContent: 'center',
|
||||||
: ''
|
alignItems: 'center'
|
||||||
}
|
}}
|
||||||
disableTransitions={true}
|
|
||||||
>
|
>
|
||||||
{dictData.value?.label}
|
{dictOptions.map((dict: DictDataType) => {
|
||||||
</ElTag>
|
if (valueArr.value.includes(dict.value)) {
|
||||||
|
if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') {
|
||||||
|
dict.colorType = ''
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题
|
||||||
|
<ElTag
|
||||||
|
style={dict?.cssClass ? 'color: #fff' : ''}
|
||||||
|
type={dict?.colorType}
|
||||||
|
color={dict?.cssClass && isHexColor(dict?.cssClass) ? dict?.cssClass : ''}
|
||||||
|
disableTransitions={true}
|
||||||
|
>
|
||||||
|
{dict?.label}
|
||||||
|
</ElTag>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return () => rederDictTag()
|
return () => renderDictTag()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -95,6 +95,7 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
|
||||||
.editor-left {
|
.editor-left {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
|
box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
|
||||||
|
|
||||||
:deep(.el-collapse) {
|
:deep(.el-collapse) {
|
||||||
|
|
|
@ -96,11 +96,6 @@ const editorConfig = computed((): IEditorConfig => {
|
||||||
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
// 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
|
||||||
allowedFileTypes: ['image/*'],
|
allowedFileTypes: ['image/*'],
|
||||||
|
|
||||||
// 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
|
|
||||||
meta: { updateSupport: 0 },
|
|
||||||
// 将 meta 拼接到 url 参数中,默认 false
|
|
||||||
metaWithUrl: true,
|
|
||||||
|
|
||||||
// 自定义增加 http header
|
// 自定义增加 http header
|
||||||
headers: {
|
headers: {
|
||||||
Accept: '*',
|
Accept: '*',
|
||||||
|
@ -108,9 +103,6 @@ const editorConfig = computed((): IEditorConfig => {
|
||||||
'tenant-id': getTenantId()
|
'tenant-id': getTenantId()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 跨域是否传递 cookie ,默认为 false
|
|
||||||
withCredentials: true,
|
|
||||||
|
|
||||||
// 超时时间,默认为 10 秒
|
// 超时时间,默认为 10 秒
|
||||||
timeout: 5 * 1000, // 5 秒
|
timeout: 5 * 1000, // 5 秒
|
||||||
|
|
||||||
|
@ -119,7 +111,7 @@ const editorConfig = computed((): IEditorConfig => {
|
||||||
|
|
||||||
// 上传之前触发
|
// 上传之前触发
|
||||||
onBeforeUpload(file: File) {
|
onBeforeUpload(file: File) {
|
||||||
console.log(file)
|
// console.log(file)
|
||||||
return file
|
return file
|
||||||
},
|
},
|
||||||
// 上传进度的回调函数
|
// 上传进度的回调函数
|
||||||
|
@ -142,6 +134,54 @@ const editorConfig = computed((): IEditorConfig => {
|
||||||
customInsert(res: any, insertFn: InsertFnType) {
|
customInsert(res: any, insertFn: InsertFnType) {
|
||||||
insertFn(res.data, 'image', res.data)
|
insertFn(res.data, 'image', res.data)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
['uploadVideo']: {
|
||||||
|
server: import.meta.env.VITE_UPLOAD_URL,
|
||||||
|
// 单个文件的最大体积限制,默认为 10M
|
||||||
|
maxFileSize: 10 * 1024 * 1024,
|
||||||
|
// 最多可上传几个文件,默认为 100
|
||||||
|
maxNumberOfFiles: 10,
|
||||||
|
// 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
|
||||||
|
allowedFileTypes: ['video/*'],
|
||||||
|
|
||||||
|
// 自定义增加 http header
|
||||||
|
headers: {
|
||||||
|
Accept: '*',
|
||||||
|
Authorization: 'Bearer ' + getAccessToken(),
|
||||||
|
'tenant-id': getTenantId()
|
||||||
|
},
|
||||||
|
|
||||||
|
// 超时时间,默认为 30 秒
|
||||||
|
timeout: 15 * 1000, // 15 秒
|
||||||
|
|
||||||
|
// form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
|
||||||
|
fieldName: 'file',
|
||||||
|
|
||||||
|
// 上传之前触发
|
||||||
|
onBeforeUpload(file: File) {
|
||||||
|
// console.log(file)
|
||||||
|
return file
|
||||||
|
},
|
||||||
|
// 上传进度的回调函数
|
||||||
|
onProgress(progress: number) {
|
||||||
|
// progress 是 0-100 的数字
|
||||||
|
console.log('progress', progress)
|
||||||
|
},
|
||||||
|
onSuccess(file: File, res: any) {
|
||||||
|
console.log('onSuccess', file, res)
|
||||||
|
},
|
||||||
|
onFailed(file: File, res: any) {
|
||||||
|
alert(res.message)
|
||||||
|
console.log('onFailed', file, res)
|
||||||
|
},
|
||||||
|
onError(file: File, err: any, res: any) {
|
||||||
|
alert(err.message)
|
||||||
|
console.error('onError', file, err, res)
|
||||||
|
},
|
||||||
|
// 自定义插入图片
|
||||||
|
customInsert(res: any, insertFn: InsertFnType) {
|
||||||
|
insertFn(res.data, 'mp4', res.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
uploadImgShowBase64: true
|
uploadImgShowBase64: true
|
||||||
|
|
|
@ -1,43 +1,12 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="contentRef" class="markdown-view" v-html="contentHtml"></div>
|
<div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useClipboard} from "@vueuse/core";
|
import { useClipboard } from '@vueuse/core'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
import {marked} from 'marked'
|
|
||||||
import 'highlight.js/styles/vs2015.min.css'
|
import 'highlight.js/styles/vs2015.min.css'
|
||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
import {ref} from "vue";
|
|
||||||
|
|
||||||
const {copy} = useClipboard() // 初始化 copy 到粘贴板
|
|
||||||
const contentRef = ref()
|
|
||||||
|
|
||||||
// 代码高亮:https://highlightjs.org/
|
|
||||||
// 转换 markdown:marked
|
|
||||||
|
|
||||||
// marked 渲染器
|
|
||||||
const renderer = {
|
|
||||||
code(code, language, c) {
|
|
||||||
let highlightHtml
|
|
||||||
try {
|
|
||||||
highlightHtml = hljs.highlight(code, {language: language, ignoreIllegals: true}).value
|
|
||||||
} catch (e) {
|
|
||||||
// skip
|
|
||||||
}
|
|
||||||
const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
|
|
||||||
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置 marked
|
|
||||||
marked.use({
|
|
||||||
renderer: renderer
|
|
||||||
})
|
|
||||||
|
|
||||||
// 渲染的html内容
|
|
||||||
const contentHtml = ref<any>()
|
|
||||||
|
|
||||||
// 定义组件属性
|
// 定义组件属性
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
@ -47,39 +16,39 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 将 props 变为引用类型
|
const message = useMessage() // 消息弹窗
|
||||||
const { content } = toRefs(props)
|
const { copy } = useClipboard() // 初始化 copy 到粘贴板
|
||||||
|
const contentRef = ref()
|
||||||
|
|
||||||
// 监听 content 变化
|
const md = new MarkdownIt({
|
||||||
watch(content, async (newValue, oldValue) => {
|
highlight: function (str, lang) {
|
||||||
await renderMarkdown(newValue);
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
|
||||||
|
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>`
|
||||||
|
} catch (__) {}
|
||||||
|
}
|
||||||
|
return ``
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 渲染 markdown
|
/** 渲染 markdown */
|
||||||
const renderMarkdown = async (content: string) => {
|
const renderedMarkdown = computed(() => {
|
||||||
contentHtml.value = await marked(content)
|
return md.render(props.content)
|
||||||
}
|
})
|
||||||
|
|
||||||
// 组件挂在时
|
/** 初始化 **/
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 解析转换 markdown
|
|
||||||
await renderMarkdown(props.content as string);
|
|
||||||
//
|
|
||||||
// 添加 copy 监听
|
// 添加 copy 监听
|
||||||
contentRef.value.addEventListener('click', (e: any) => {
|
contentRef.value.addEventListener('click', (e: any) => {
|
||||||
console.log(e)
|
|
||||||
if (e.target.id === 'copy') {
|
if (e.target.id === 'copy') {
|
||||||
copy(e.target?.dataset?.copy)
|
copy(e.target?.dataset?.copy)
|
||||||
ElMessage({
|
message.success('复制成功!')
|
||||||
message: '复制成功!',
|
|
||||||
type: 'success'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.markdown-view {
|
.markdown-view {
|
||||||
font-family: PingFang SC;
|
font-family: PingFang SC;
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件
|
格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- TODO @puhui999:1)表单展示的时候,位置会偏掉,已发微信;2)disable 的时候,应该把【删除】按钮也隐藏掉? -->
|
||||||
<template #file="row">
|
<template #file="row">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span>{{ row.file.name }}</span>
|
<span>{{ row.file.name }}</span>
|
||||||
|
|
|
@ -129,7 +129,7 @@ const updateFlowType = (flowType) => {
|
||||||
conditionExpression: null
|
conditionExpression: null
|
||||||
})
|
})
|
||||||
bpmnInstances().modeling.updateProperties(toRaw(bpmnElementSource.value), {
|
bpmnInstances().modeling.updateProperties(toRaw(bpmnElementSource.value), {
|
||||||
default: bpmnElement.value
|
default: toRaw(bpmnElement.value)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,9 +43,6 @@ import { CommonStatusEnum } from '@/utils/constants'
|
||||||
/** BPM 流程 表单 */
|
/** BPM 流程 表单 */
|
||||||
defineOptions({ name: 'ProcessListenerDialog' })
|
defineOptions({ name: 'ProcessListenerDialog' })
|
||||||
|
|
||||||
const { t } = useI18n() // 国际化
|
|
||||||
const message = useMessage() // 消息弹窗
|
|
||||||
|
|
||||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||||
const loading = ref(true) // 列表的加载中
|
const loading = ref(true) // 列表的加载中
|
||||||
const list = ref<ProcessListenerVO[]>([]) // 列表的数据
|
const list = ref<ProcessListenerVO[]>([]) // 列表的数据
|
||||||
|
@ -53,17 +50,23 @@ const total = ref(0) // 列表的总页数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
pageNo: 1,
|
pageNo: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
type: undefined,
|
type: '',
|
||||||
status: CommonStatusEnum.ENABLE
|
status: CommonStatusEnum.ENABLE
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 打开弹窗 */
|
/** 打开弹窗 */
|
||||||
const open = async (type: string) => {
|
const open = async (type: string) => {
|
||||||
|
queryParams.pageNo = 1
|
||||||
|
queryParams.type = type
|
||||||
|
getList()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
queryParams.pageNo = 1
|
|
||||||
queryParams.type = type
|
|
||||||
const data = await ProcessListenerApi.getProcessListenerPage(queryParams)
|
const data = await ProcessListenerApi.getProcessListenerPage(queryParams)
|
||||||
list.value = data.list
|
list.value = data.list
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
@ -71,7 +74,6 @@ const open = async (type: string) => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
|
||||||
|
|
||||||
/** 提交表单 */
|
/** 提交表单 */
|
||||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||||
|
|
|
@ -28,9 +28,6 @@ import { ProcessExpressionApi, ProcessExpressionVO } from '@/api/bpm/processExpr
|
||||||
/** BPM 流程 表单 */
|
/** BPM 流程 表单 */
|
||||||
defineOptions({ name: 'ProcessExpressionDialog' })
|
defineOptions({ name: 'ProcessExpressionDialog' })
|
||||||
|
|
||||||
const { t } = useI18n() // 国际化
|
|
||||||
const message = useMessage() // 消息弹窗
|
|
||||||
|
|
||||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
const dialogVisible = ref(false) // 弹窗的是否展示
|
||||||
const loading = ref(true) // 列表的加载中
|
const loading = ref(true) // 列表的加载中
|
||||||
const list = ref<ProcessExpressionVO[]>([]) // 列表的数据
|
const list = ref<ProcessExpressionVO[]>([]) // 列表的数据
|
||||||
|
@ -38,17 +35,23 @@ const total = ref(0) // 列表的总页数
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
pageNo: 1,
|
pageNo: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
type: undefined,
|
type: '',
|
||||||
status: CommonStatusEnum.ENABLE
|
status: CommonStatusEnum.ENABLE
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 打开弹窗 */
|
/** 打开弹窗 */
|
||||||
const open = async (type: string) => {
|
const open = (type: string) => {
|
||||||
|
queryParams.pageNo = 1
|
||||||
|
queryParams.type = type
|
||||||
|
getList()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
queryParams.pageNo = 1
|
|
||||||
queryParams.type = type
|
|
||||||
const data = await ProcessExpressionApi.getProcessExpressionPage(queryParams)
|
const data = await ProcessExpressionApi.getProcessExpressionPage(queryParams)
|
||||||
list.value = data.list
|
list.value = data.list
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
@ -56,7 +59,6 @@ const open = async (type: string) => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
|
|
||||||
|
|
||||||
/** 提交表单 */
|
/** 提交表单 */
|
||||||
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
|
||||||
|
|
|
@ -135,6 +135,7 @@ import * as PostApi from '@/api/system/post'
|
||||||
import * as UserApi from '@/api/system/user'
|
import * as UserApi from '@/api/system/user'
|
||||||
import * as UserGroupApi from '@/api/bpm/userGroup'
|
import * as UserGroupApi from '@/api/bpm/userGroup'
|
||||||
import ProcessExpressionDialog from './ProcessExpressionDialog.vue'
|
import ProcessExpressionDialog from './ProcessExpressionDialog.vue'
|
||||||
|
import { ProcessExpressionVO } from '@/api/bpm/processExpression'
|
||||||
|
|
||||||
defineOptions({ name: 'UserTask' })
|
defineOptions({ name: 'UserTask' })
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
@ -197,8 +198,9 @@ const processExpressionDialogRef = ref()
|
||||||
const openProcessExpressionDialog = async () => {
|
const openProcessExpressionDialog = async () => {
|
||||||
processExpressionDialogRef.value.open()
|
processExpressionDialogRef.value.open()
|
||||||
}
|
}
|
||||||
const selectProcessExpression = (expression) => {
|
const selectProcessExpression = (expression: ProcessExpressionVO) => {
|
||||||
userTaskForm.value.candidateParam = [expression.expression]
|
userTaskForm.value.candidateParam = [expression.expression]
|
||||||
|
updateElementTask()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
|
@ -81,7 +81,7 @@ service.interceptors.request.use(
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
// Do something with request error
|
// Do something with request error
|
||||||
console.log(error) // for debug
|
console.log(error) // for debug
|
||||||
Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -174,6 +174,7 @@ service.interceptors.response.use(
|
||||||
if (msg === '无效的刷新令牌') {
|
if (msg === '无效的刷新令牌') {
|
||||||
// hard coding:忽略这个提示,直接登出
|
// hard coding:忽略这个提示,直接登出
|
||||||
console.log(msg)
|
console.log(msg)
|
||||||
|
return handleAuthorized()
|
||||||
} else {
|
} else {
|
||||||
ElNotification.error({ title: msg })
|
ElNotification.error({ title: msg })
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,6 +90,11 @@ export default defineComponent({
|
||||||
backgroundColor="var(--left-menu-bg-color)"
|
backgroundColor="var(--left-menu-bg-color)"
|
||||||
textColor="var(--left-menu-text-color)"
|
textColor="var(--left-menu-text-color)"
|
||||||
activeTextColor="var(--left-menu-text-active-color)"
|
activeTextColor="var(--left-menu-text-active-color)"
|
||||||
|
popperClass={
|
||||||
|
unref(menuMode) === 'vertical'
|
||||||
|
? `${prefixCls}-popper--vertical`
|
||||||
|
: `${prefixCls}-popper--horizontal`
|
||||||
|
}
|
||||||
onSelect={menuSelect}
|
onSelect={menuSelect}
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
|
|
|
@ -52,7 +52,7 @@ async function goLogin() {
|
||||||
// 登出后清理
|
// 登出后清理
|
||||||
deleteUserCache() // 清空用户缓存
|
deleteUserCache() // 清空用户缓存
|
||||||
tagsViewStore.delAllViews()
|
tagsViewStore.delAllViews()
|
||||||
resetRouter() // 重置静态路由表
|
// resetRouter() // 重置静态路由表
|
||||||
lockStore.resetLockInfo()
|
lockStore.resetLockInfo()
|
||||||
replace('/login')
|
replace('/login')
|
||||||
}
|
}
|
||||||
|
|
|
@ -341,7 +341,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
||||||
component: () => import('@/views/mall/product/spu/form/index.vue'),
|
component: () => import('@/views/mall/product/spu/form/index.vue'),
|
||||||
name: 'ProductSpuAdd',
|
name: 'ProductSpuAdd',
|
||||||
meta: {
|
meta: {
|
||||||
noCache: true,
|
noCache: false, // 需要缓存
|
||||||
hidden: true,
|
hidden: true,
|
||||||
canTo: true,
|
canTo: true,
|
||||||
icon: 'ep:edit',
|
icon: 'ep:edit',
|
||||||
|
@ -573,6 +573,26 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
||||||
component: () => import('@/views/crm/product/detail/index.vue')
|
component: () => import('@/views/crm/product/detail/index.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ai',
|
||||||
|
component: Layout,
|
||||||
|
name: 'Ai',
|
||||||
|
meta: {
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'image/square',
|
||||||
|
component: () => import('@/views/ai/image/square/index.vue'),
|
||||||
|
name: 'AiImageSquare',
|
||||||
|
meta: {
|
||||||
|
title: '绘图作品',
|
||||||
|
icon: 'ep:home-filled',
|
||||||
|
noCache: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -109,6 +109,14 @@ export const PayChannelEnum = {
|
||||||
code: 'wx_app',
|
code: 'wx_app',
|
||||||
name: '微信 APP 支付'
|
name: '微信 APP 支付'
|
||||||
},
|
},
|
||||||
|
WX_NATIVE: {
|
||||||
|
code: 'wx_native',
|
||||||
|
name: '微信 Native 支付'
|
||||||
|
},
|
||||||
|
WX_WAP: {
|
||||||
|
code: 'wx_wap',
|
||||||
|
name: '微信 WAP 网站支付'
|
||||||
|
},
|
||||||
WX_BAR: {
|
WX_BAR: {
|
||||||
code: 'wx_bar',
|
code: 'wx_bar',
|
||||||
name: '微信条码支付'
|
name: '微信条码支付'
|
||||||
|
|
|
@ -125,7 +125,6 @@ export enum DICT_TYPE {
|
||||||
SYSTEM_SMS_TEMPLATE_TYPE = 'system_sms_template_type',
|
SYSTEM_SMS_TEMPLATE_TYPE = 'system_sms_template_type',
|
||||||
SYSTEM_SMS_SEND_STATUS = 'system_sms_send_status',
|
SYSTEM_SMS_SEND_STATUS = 'system_sms_send_status',
|
||||||
SYSTEM_SMS_RECEIVE_STATUS = 'system_sms_receive_status',
|
SYSTEM_SMS_RECEIVE_STATUS = 'system_sms_receive_status',
|
||||||
SYSTEM_ERROR_CODE_TYPE = 'system_error_code_type',
|
|
||||||
SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type',
|
SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type',
|
||||||
SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
|
SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
|
||||||
SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
|
SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
|
||||||
|
@ -219,5 +218,13 @@ export enum DICT_TYPE {
|
||||||
ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型
|
ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型
|
||||||
|
|
||||||
// ========== AI - 人工智能模块 ==========
|
// ========== AI - 人工智能模块 ==========
|
||||||
AI_PLATFORM = 'ai_platform' // AI 平台
|
AI_PLATFORM = 'ai_platform', // AI 平台
|
||||||
|
AI_IMAGE_STATUS = 'ai_image_status', // AI 图片状态
|
||||||
|
AI_MUSIC_STATUS = 'ai_music_status', // AI 音乐状态
|
||||||
|
AI_GENERATE_MODE = 'ai_generate_mode', // AI 生成模式
|
||||||
|
AI_WRITE_TYPE = 'ai_write_type', // AI 写作类型
|
||||||
|
AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度
|
||||||
|
AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
|
||||||
|
AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
|
||||||
|
AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,9 +29,42 @@ const download = {
|
||||||
html: (data: Blob, fileName: string) => {
|
html: (data: Blob, fileName: string) => {
|
||||||
download0(data, fileName, 'text/html')
|
download0(data, fileName, 'text/html')
|
||||||
},
|
},
|
||||||
// 下载 MarkdownView 方法
|
// 下载 Markdown 方法
|
||||||
markdown: (data: Blob, fileName: string) => {
|
markdown: (data: Blob, fileName: string) => {
|
||||||
download0(data, fileName, 'text/markdown')
|
download0(data, fileName, 'text/markdown')
|
||||||
|
},
|
||||||
|
// 下载图片(允许跨域)
|
||||||
|
image: ({
|
||||||
|
url,
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
drawWithImageSize = true
|
||||||
|
}: {
|
||||||
|
url: string
|
||||||
|
canvasWidth?: number // 指定画布宽度
|
||||||
|
canvasHeight?: number // 指定画布高度
|
||||||
|
drawWithImageSize?: boolean // 将图片绘制在画布上时带上图片的宽高值, 默认是要带上的
|
||||||
|
}) => {
|
||||||
|
const image = new Image()
|
||||||
|
// image.setAttribute('crossOrigin', 'anonymous')
|
||||||
|
image.src = url
|
||||||
|
image.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = canvasWidth || image.width
|
||||||
|
canvas.height = canvasHeight || image.height
|
||||||
|
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
|
||||||
|
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
if (drawWithImageSize) {
|
||||||
|
ctx.drawImage(image, 0, 0, image.width, image.height)
|
||||||
|
} else {
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
}
|
||||||
|
const url = canvas.toDataURL('image/png')
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'image.png'
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -313,7 +313,7 @@ export const fenToYuan = (price: string | number): string => {
|
||||||
*/
|
*/
|
||||||
export const calculateRelativeRate = (value?: number, reference?: number) => {
|
export const calculateRelativeRate = (value?: number, reference?: number) => {
|
||||||
// 防止除0
|
// 防止除0
|
||||||
if (!reference) return 0
|
if (!reference || reference == 0) return 0
|
||||||
|
|
||||||
return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
|
return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
|
||||||
}
|
}
|
||||||
|
|
|
@ -184,9 +184,9 @@ const loginData = reactive({
|
||||||
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
|
captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
|
||||||
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
|
tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
|
||||||
loginForm: {
|
loginForm: {
|
||||||
tenantName: '芋道源码',
|
tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
|
||||||
username: 'admin',
|
username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
|
||||||
password: 'admin123',
|
password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
|
||||||
captchaVerification: '',
|
captchaVerification: '',
|
||||||
rememberMe: true // 默认记录我。如果不需要,可手动修改
|
rememberMe: true // 默认记录我。如果不需要,可手动修改
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
<!-- AI 对话 -->
|
<!-- AI 对话 -->
|
||||||
<template>
|
<template>
|
||||||
<el-aside width="260px" class="conversation-container" style="height: 100%;">
|
<el-aside width="260px" class="conversation-container h-100%">
|
||||||
|
|
||||||
<!-- 左顶部:对话 -->
|
<!-- 左顶部:对话 -->
|
||||||
<div style="height: 100%;">
|
<div class="h-100%">
|
||||||
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
|
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
|
||||||
<Icon icon="ep:plus" class="mr-5px"/>
|
<Icon icon="ep:plus" class="mr-5px" />
|
||||||
新建对话
|
新建对话
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
@ -18,17 +17,20 @@
|
||||||
@keyup="searchConversation"
|
@keyup="searchConversation"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Icon icon="ep:search"/>
|
<Icon icon="ep:search" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<!-- 左中间:对话列表 -->
|
<!-- 左中间:对话列表 -->
|
||||||
<div class="conversation-list">
|
<div class="conversation-list">
|
||||||
|
<!-- 情况一:加载中 -->
|
||||||
<el-empty v-if="loading" description="." :v-loading="loading" />
|
<el-empty v-if="loading" description="." :v-loading="loading" />
|
||||||
|
<!-- 情况二:按照 group 分组,展示聊天会话 list 列表 -->
|
||||||
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
|
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
|
||||||
<div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length">
|
<div
|
||||||
|
class="conversation-item classify-title"
|
||||||
|
v-if="conversationMap[conversationKey].length"
|
||||||
|
>
|
||||||
<el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
|
<el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -40,76 +42,71 @@
|
||||||
@mouseout="hoverConversationId = ''"
|
@mouseout="hoverConversationId = ''"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'"
|
:class="
|
||||||
|
conversation.id === activeConversationId ? 'conversation active' : 'conversation'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="title-wrapper">
|
<div class="title-wrapper">
|
||||||
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg"/>
|
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
|
||||||
<span class="title">{{ conversation.title }}</span>
|
<span class="title">{{ conversation.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-wrapper" v-show="hoverConversationId === conversation.id">
|
<div class="button-wrapper" v-show="hoverConversationId === conversation.id">
|
||||||
<el-button class="btn" link @click.stop="handlerTop(conversation)" >
|
<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"><Top /></el-icon>
|
||||||
<el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
|
<el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
|
<el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
|
||||||
<el-icon title="编辑" >
|
<el-icon title="编辑">
|
||||||
<Icon icon="ep:edit"/>
|
<Icon icon="ep:edit" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
|
<el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
|
||||||
<el-icon title="删除对话" >
|
<el-icon title="删除对话">
|
||||||
<Icon icon="ep:delete"/>
|
<Icon icon="ep:delete" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 底部站位 -->
|
<!-- 底部占位 -->
|
||||||
<div style="height: 160px; width: 100%;"></div>
|
<div class="h-160px w-100%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 左底部:工具栏 -->
|
<!-- 左底部:工具栏 -->
|
||||||
<!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
|
|
||||||
<div class="tool-box">
|
<div class="tool-box">
|
||||||
<div @click="handleRoleRepository">
|
<div @click="handleRoleRepository">
|
||||||
<Icon icon="ep:user"/>
|
<Icon icon="ep:user" />
|
||||||
<el-text size="small">角色仓库</el-text>
|
<el-text size="small">角色仓库</el-text>
|
||||||
</div>
|
</div>
|
||||||
<div @click="handleClearConversation">
|
<div @click="handleClearConversation">
|
||||||
<Icon icon="ep:delete"/>
|
<Icon icon="ep:delete" />
|
||||||
<el-text size="small">清空未置顶对话</el-text>
|
<el-text size="small">清空未置顶对话</el-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============= 额外组件 ============= -->
|
|
||||||
|
|
||||||
<!-- 角色仓库抽屉 -->
|
<!-- 角色仓库抽屉 -->
|
||||||
<el-drawer v-model="drawer" title="角色仓库" size="754px">
|
<el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px">
|
||||||
<Role/>
|
<RoleRepository />
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
</el-aside>
|
</el-aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
|
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||||
import {ref} from "vue";
|
import RoleRepository from '../role/RoleRepository.vue'
|
||||||
import Role from "@/views/ai/chat/role/index.vue";
|
import { Bottom, Top } from '@element-plus/icons-vue'
|
||||||
import {Bottom, Top} from "@element-plus/icons-vue";
|
|
||||||
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
||||||
|
|
||||||
const message = useMessage() // 消息弹窗
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
// 定义属性
|
// 定义属性
|
||||||
const searchName = ref<string>('') // 对话搜索
|
const searchName = ref<string>('') // 对话搜索
|
||||||
const activeConversationId = ref<string | null>(null) // 选中的对话,默认为 null
|
const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null
|
||||||
const hoverConversationId = ref<string | null>(null) // 悬浮上去的对话
|
const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话
|
||||||
const conversationList = ref([] as ChatConversationVO[]) // 对话列表
|
const conversationList = ref([] as ChatConversationVO[]) // 对话列表
|
||||||
const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
|
const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
|
||||||
const drawer = ref<boolean>(false) // 角色仓库抽屉 TODO @fan:roleDrawer 会不会好点哈
|
|
||||||
const loading = ref<boolean>(false) // 加载中
|
const loading = ref<boolean>(false) // 加载中
|
||||||
const loadingTime = ref<any>() // 加载中定时器
|
const loadingTime = ref<any>() // 加载中定时器
|
||||||
|
|
||||||
|
@ -129,75 +126,58 @@ const emits = defineEmits([
|
||||||
'onConversationDelete'
|
'onConversationDelete'
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/** 搜索对话 */
|
||||||
* 对话 - 搜索
|
|
||||||
*/
|
|
||||||
const searchConversation = async (e) => {
|
const searchConversation = async (e) => {
|
||||||
// 恢复数据
|
// 恢复数据
|
||||||
if (!searchName.value.trim().length) {
|
if (!searchName.value.trim().length) {
|
||||||
conversationMap.value = await conversationTimeGroup(conversationList.value)
|
conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
|
||||||
} else {
|
} else {
|
||||||
// 过滤
|
// 过滤
|
||||||
const filterValues = conversationList.value.filter(item => {
|
const filterValues = conversationList.value.filter((item) => {
|
||||||
return item.title.includes(searchName.value.trim())
|
return item.title.includes(searchName.value.trim())
|
||||||
})
|
})
|
||||||
conversationMap.value = await conversationTimeGroup(filterValues)
|
conversationMap.value = await getConversationGroupByCreateTime(filterValues)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 点击对话 */
|
||||||
* 对话 - 点击
|
const handleConversationClick = async (id: number) => {
|
||||||
*/
|
|
||||||
const handleConversationClick = async (id: string) => {
|
|
||||||
// 过滤出选中的对话
|
// 过滤出选中的对话
|
||||||
const filterConversation = conversationList.value.filter(item => {
|
const filterConversation = conversationList.value.filter((item) => {
|
||||||
return item.id === id
|
return item.id === id
|
||||||
})
|
})
|
||||||
// 回调 onConversationClick
|
// 回调 onConversationClick
|
||||||
// TODO @fan: 这里 idea 会报黄色警告,有办法解下么?
|
// noinspection JSVoidFunctionReturnValueUsed
|
||||||
const res = emits('onConversationClick', filterConversation[0])
|
const success = emits('onConversationClick', filterConversation[0])
|
||||||
// 切换对话
|
// 切换对话
|
||||||
if (res) {
|
if (success) {
|
||||||
activeConversationId.value = id
|
activeConversationId.value = id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 获取对话列表 */
|
||||||
* 对话 - 获取列表
|
|
||||||
*/
|
|
||||||
const getChatConversationList = async () => {
|
const getChatConversationList = async () => {
|
||||||
try {
|
try {
|
||||||
// 0. 加载中
|
// 加载中
|
||||||
loadingTime.value = setTimeout(() => {
|
loadingTime.value = setTimeout(() => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
}, 50)
|
}, 50)
|
||||||
// 1. 获取 对话数据
|
|
||||||
const res = await ChatConversationApi.getChatConversationMyList()
|
// 1.1 获取 对话数据
|
||||||
// 2. 排序
|
conversationList.value = await ChatConversationApi.getChatConversationMyList()
|
||||||
res.sort((a, b) => {
|
// 1.2 排序
|
||||||
|
conversationList.value.sort((a, b) => {
|
||||||
return b.createTime - a.createTime
|
return b.createTime - a.createTime
|
||||||
})
|
})
|
||||||
conversationList.value = res
|
// 1.3 没有任何对话情况
|
||||||
// 3. 默认选中
|
|
||||||
if (!activeId?.value) {
|
|
||||||
// await handleConversationClick(res[0].id)
|
|
||||||
} else {
|
|
||||||
// tip: 删除的刚好是选中的,那么需要重新挑选一个来进行选中
|
|
||||||
// const filterConversationList = conversationList.value.filter(item => {
|
|
||||||
// return item.id === activeId.value
|
|
||||||
// })
|
|
||||||
// if (filterConversationList.length <= 0) {
|
|
||||||
// await handleConversationClick(res[0].id)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
// 4. 没有任何对话情况
|
|
||||||
if (conversationList.value.length === 0) {
|
if (conversationList.value.length === 0) {
|
||||||
activeConversationId.value = null
|
activeConversationId.value = null
|
||||||
conversationMap.value = {}
|
conversationMap.value = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 5. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30天前)
|
|
||||||
conversationMap.value = await conversationTimeGroup(conversationList.value)
|
// 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
|
||||||
|
conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
|
||||||
} finally {
|
} finally {
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
if (loadingTime.value) {
|
if (loadingTime.value) {
|
||||||
|
@ -208,31 +188,33 @@ const getChatConversationList = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversationTimeGroup = async (list: ChatConversationVO[]) => {
|
/** 按照 creteTime 创建时间,进行分组 */
|
||||||
|
const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => {
|
||||||
// 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
|
// 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
|
||||||
|
// noinspection NonAsciiCharacters
|
||||||
const groupMap = {
|
const groupMap = {
|
||||||
'置顶': [],
|
置顶: [],
|
||||||
'今天': [],
|
今天: [],
|
||||||
'一天前': [],
|
一天前: [],
|
||||||
'三天前': [],
|
三天前: [],
|
||||||
'七天前': [],
|
七天前: [],
|
||||||
'三十天前': []
|
三十天前: []
|
||||||
}
|
}
|
||||||
// 当前时间的时间戳
|
// 当前时间的时间戳
|
||||||
const now = Date.now();
|
const now = Date.now()
|
||||||
// 定义时间间隔常量(单位:毫秒)
|
// 定义时间间隔常量(单位:毫秒)
|
||||||
const oneDay = 24 * 60 * 60 * 1000;
|
const oneDay = 24 * 60 * 60 * 1000
|
||||||
const threeDays = 3 * oneDay;
|
const threeDays = 3 * oneDay
|
||||||
const sevenDays = 7 * oneDay;
|
const sevenDays = 7 * oneDay
|
||||||
const thirtyDays = 30 * oneDay;
|
const thirtyDays = 30 * oneDay
|
||||||
for (const conversation: ChatConversationVO of list) {
|
for (const conversation of list) {
|
||||||
// 置顶
|
// 置顶
|
||||||
if (conversation.pinned) {
|
if (conversation.pinned) {
|
||||||
groupMap['置顶'].push(conversation)
|
groupMap['置顶'].push(conversation)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 计算时间差(单位:毫秒)
|
// 计算时间差(单位:毫秒)
|
||||||
const diff = now - conversation.updateTime;
|
const diff = now - conversation.createTime
|
||||||
// 根据时间间隔判断
|
// 根据时间间隔判断
|
||||||
if (diff < oneDay) {
|
if (diff < oneDay) {
|
||||||
groupMap['今天'].push(conversation)
|
groupMap['今天'].push(conversation)
|
||||||
|
@ -246,13 +228,10 @@ const conversationTimeGroup = async (list: ChatConversationVO[]) => {
|
||||||
groupMap['三十天前'].push(conversation)
|
groupMap['三十天前'].push(conversation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('----groupMap', groupMap)
|
|
||||||
return groupMap
|
return groupMap
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 新建对话 */
|
||||||
* 对话 - 新建
|
|
||||||
*/
|
|
||||||
const createConversation = async () => {
|
const createConversation = async () => {
|
||||||
// 1. 新建对话
|
// 1. 新建对话
|
||||||
const conversationId = await ChatConversationApi.createChatConversationMy(
|
const conversationId = await ChatConversationApi.createChatConversationMy(
|
||||||
|
@ -266,12 +245,10 @@ const createConversation = async () => {
|
||||||
emits('onConversationCreate')
|
emits('onConversationCreate')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 修改对话的标题 */
|
||||||
* 对话 - 更新标题
|
|
||||||
*/
|
|
||||||
const updateConversationTitle = async (conversation: ChatConversationVO) => {
|
const updateConversationTitle = async (conversation: ChatConversationVO) => {
|
||||||
// 1. 二次确认
|
// 1. 二次确认
|
||||||
const {value} = await ElMessageBox.prompt('修改标题', {
|
const { value } = await ElMessageBox.prompt('修改标题', {
|
||||||
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
|
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
|
||||||
inputErrorMessage: '标题不能为空',
|
inputErrorMessage: '标题不能为空',
|
||||||
inputValue: conversation.title
|
inputValue: conversation.title
|
||||||
|
@ -285,7 +262,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => {
|
||||||
// 3. 刷新列表
|
// 3. 刷新列表
|
||||||
await getChatConversationList()
|
await getChatConversationList()
|
||||||
// 4. 过滤当前切换的
|
// 4. 过滤当前切换的
|
||||||
const filterConversationList = conversationList.value.filter(item => {
|
const filterConversationList = conversationList.value.filter((item) => {
|
||||||
return item.id === conversation.id
|
return item.id === conversation.id
|
||||||
})
|
})
|
||||||
if (filterConversationList.length > 0) {
|
if (filterConversationList.length > 0) {
|
||||||
|
@ -296,9 +273,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 删除聊天对话 */
|
||||||
* 删除聊天对话
|
|
||||||
*/
|
|
||||||
const deleteChatConversation = async (conversation: ChatConversationVO) => {
|
const deleteChatConversation = async (conversation: ChatConversationVO) => {
|
||||||
try {
|
try {
|
||||||
// 删除的二次确认
|
// 删除的二次确认
|
||||||
|
@ -310,15 +285,29 @@ const deleteChatConversation = async (conversation: ChatConversationVO) => {
|
||||||
await getChatConversationList()
|
await getChatConversationList()
|
||||||
// 回调
|
// 回调
|
||||||
emits('onConversationDelete', conversation)
|
emits('onConversationDelete', conversation)
|
||||||
} catch {
|
} catch {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 清空对话 */
|
||||||
* 对话置顶
|
const handleClearConversation = async () => {
|
||||||
*/
|
try {
|
||||||
// TODO @fan:应该是 handleXXX,handler 是名词哈
|
await message.confirm('确认后对话会全部清空,置顶的对话除外。')
|
||||||
const handlerTop = async (conversation: ChatConversationVO) => {
|
await ChatConversationApi.deleteChatConversationMyByUnpinned()
|
||||||
|
ElMessage({
|
||||||
|
message: '操作成功!',
|
||||||
|
type: 'success'
|
||||||
|
})
|
||||||
|
// 清空 对话 和 对话内容
|
||||||
|
activeConversationId.value = null
|
||||||
|
// 获取 对话列表
|
||||||
|
await getChatConversationList()
|
||||||
|
// 回调 方法
|
||||||
|
emits('onConversationClear')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对话置顶 */
|
||||||
|
const handleTop = async (conversation: ChatConversationVO) => {
|
||||||
// 更新对话置顶
|
// 更新对话置顶
|
||||||
conversation.pinned = !conversation.pinned
|
conversation.pinned = !conversation.pinned
|
||||||
await ChatConversationApi.updateChatConversationMy(conversation)
|
await ChatConversationApi.updateChatConversationMy(conversation)
|
||||||
|
@ -326,64 +315,29 @@ const handlerTop = async (conversation: ChatConversationVO) => {
|
||||||
await getChatConversationList()
|
await getChatConversationList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @fan:类似 ============ 分块的,最后后面也有 ============ 哈
|
// ============ 角色仓库 ============
|
||||||
// ============ 角色仓库
|
|
||||||
|
|
||||||
/**
|
/** 角色仓库抽屉 */
|
||||||
* 角色仓库抽屉
|
const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开
|
||||||
*/
|
|
||||||
const handleRoleRepository = async () => {
|
const handleRoleRepository = async () => {
|
||||||
drawer.value = !drawer.value
|
roleRepositoryOpen.value = !roleRepositoryOpen.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============= 清空对话
|
/** 监听选中的对话 */
|
||||||
|
|
||||||
/**
|
|
||||||
* 清空对话
|
|
||||||
*/
|
|
||||||
const handleClearConversation = async () => {
|
|
||||||
// TODO @fan:可以使用 await message.confirm( 简化,然后使用 await 改成同步的逻辑,会更简洁
|
|
||||||
ElMessageBox.confirm(
|
|
||||||
'确认后对话会全部清空,置顶的对话除外。',
|
|
||||||
'确认提示',
|
|
||||||
{
|
|
||||||
confirmButtonText: '确认',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
await ChatConversationApi.deleteMyAllExceptPinned()
|
|
||||||
ElMessage({
|
|
||||||
message: '操作成功!',
|
|
||||||
type: 'success'
|
|
||||||
})
|
|
||||||
// 清空 对话 和 对话内容
|
|
||||||
activeConversationId.value = null
|
|
||||||
// 获取 对话列表
|
|
||||||
await getChatConversationList()
|
|
||||||
// 回调 方法
|
|
||||||
emits('onConversationClear')
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 组件 onMounted
|
|
||||||
|
|
||||||
const { activeId } = toRefs(props)
|
const { activeId } = toRefs(props)
|
||||||
watch(activeId, async (newValue, oldValue) => {
|
watch(activeId, async (newValue, oldValue) => {
|
||||||
// 更新选中
|
|
||||||
activeConversationId.value = newValue as string
|
activeConversationId.value = newValue as string
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义 public 方法
|
// 定义 public 方法
|
||||||
defineExpose({createConversation})
|
defineExpose({ createConversation })
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 获取 对话列表
|
// 获取 对话列表
|
||||||
await getChatConversationList()
|
await getChatConversationList()
|
||||||
// 默认选中
|
// 默认选中
|
||||||
if (props.activeId != null) {
|
if (props.activeId) {
|
||||||
activeConversationId.value = props.activeId
|
activeConversationId.value = props.activeId
|
||||||
} else {
|
} else {
|
||||||
// 首次默认选中第一个
|
// 首次默认选中第一个
|
||||||
|
@ -394,18 +348,15 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
||||||
.conversation-container {
|
.conversation-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 10px;
|
padding: 10px 10px 0;
|
||||||
padding-top: 10px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.btn-new-conversation {
|
.btn-new-conversation {
|
|
@ -8,7 +8,12 @@
|
||||||
v-loading="formLoading"
|
v-loading="formLoading"
|
||||||
>
|
>
|
||||||
<el-form-item label="角色设定" prop="systemMessage">
|
<el-form-item label="角色设定" prop="systemMessage">
|
||||||
<el-input type="textarea" v-model="formData.systemMessage" rows="4" placeholder="请输入角色设定" />
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
v-model="formData.systemMessage"
|
||||||
|
rows="4"
|
||||||
|
placeholder="请输入角色设定"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="模型" prop="modelId">
|
<el-form-item label="模型" prop="modelId">
|
||||||
<el-select v-model="formData.modelId" placeholder="请选择模型">
|
<el-select v-model="formData.modelId" placeholder="请选择模型">
|
||||||
|
@ -57,10 +62,9 @@ import { CommonStatusEnum } from '@/utils/constants'
|
||||||
import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
|
import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
|
||||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||||
|
|
||||||
/** AI 聊天角色 表单 */
|
/** AI 聊天对话的更新表单 */
|
||||||
defineOptions({ name: 'ChatConversationUpdateForm' })
|
defineOptions({ name: 'ChatConversationUpdateForm' })
|
||||||
|
|
||||||
const { t } = useI18n() // 国际化
|
|
||||||
const message = useMessage() // 消息弹窗
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
const dialogVisible = ref(false) // 弹窗的是否展示
|
const dialogVisible = ref(false) // 弹窗的是否展示
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="messageContainer" style="height: 100%; overflow-y: auto; position: relative">
|
<div ref="messageContainer" class="h-100% overflow-y-auto relative">
|
||||||
<div class="chat-list" v-for="(item, index) in list" :key="index">
|
<div class="chat-list" v-for="(item, index) in list" :key="index">
|
||||||
<!-- 靠左 message -->
|
<!-- 靠左 message:system、assistant 类型 -->
|
||||||
<div class="left-message message-item" v-if="item.type !== 'user'">
|
<div class="left-message message-item" v-if="item.type !== 'user'">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<el-avatar :src="roleAvatar" />
|
<el-avatar :src="roleAvatar" />
|
||||||
|
@ -14,16 +14,16 @@
|
||||||
<MarkdownView class="left-text" :content="item.content" />
|
<MarkdownView class="left-text" :content="item.content" />
|
||||||
</div>
|
</div>
|
||||||
<div class="left-btns">
|
<div class="left-btns">
|
||||||
<el-button class="btn-cus" link @click="noCopy(item.content)">
|
<el-button class="btn-cus" link @click="copyContent(item.content)">
|
||||||
<img class="btn-image" src="@/assets/ai/copy.svg" />
|
<img class="btn-image" src="@/assets/ai/copy.svg" />
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button class="btn-cus" link @click="onDelete(item.id)">
|
<el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)">
|
||||||
<img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px" />
|
<img class="btn-image h-17px" src="@/assets/ai/delete.svg" />
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 靠右 message -->
|
<!-- 靠右 message:user 类型 -->
|
||||||
<div class="right-message message-item" v-if="item.type === 'user'">
|
<div class="right-message message-item" v-if="item.type === 'user'">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
<el-avatar :src="userAvatar" />
|
<el-avatar :src="userAvatar" />
|
||||||
|
@ -36,15 +36,11 @@
|
||||||
<div class="right-text">{{ item.content }}</div>
|
<div class="right-text">{{ item.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-btns">
|
<div class="right-btns">
|
||||||
<el-button class="btn-cus" link @click="noCopy(item.content)">
|
<el-button class="btn-cus" link @click="copyContent(item.content)">
|
||||||
<img class="btn-image" src="@/assets/ai/copy.svg" />
|
<img class="btn-image" src="@/assets/ai/copy.svg" />
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button class="btn-cus" link @click="onDelete(item.id)">
|
<el-button class="btn-cus" link @click="onDelete(item.id)">
|
||||||
<img
|
<img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" />
|
||||||
class="btn-image"
|
|
||||||
src="@/assets/ai/delete.svg"
|
|
||||||
style="height: 17px; margin-right: 12px"
|
|
||||||
/>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button class="btn-cus" link @click="onRefresh(item)">
|
<el-button class="btn-cus" link @click="onRefresh(item)">
|
||||||
<el-icon size="17"><RefreshRight /></el-icon>
|
<el-icon size="17"><RefreshRight /></el-icon>
|
||||||
|
@ -63,23 +59,25 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { PropType } from 'vue'
|
||||||
import { formatDate } from '@/utils/formatTime'
|
import { formatDate } from '@/utils/formatTime'
|
||||||
import MarkdownView from '@/components/MarkdownView/index.vue'
|
import MarkdownView from '@/components/MarkdownView/index.vue'
|
||||||
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from '@vueuse/core'
|
||||||
import { PropType } from 'vue'
|
|
||||||
import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
|
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 { ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||||
import {useUserStore} from '@/store/modules/user';
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
|
import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
|
||||||
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
const { copy } = useClipboard() // 初始化 copy 到粘贴板
|
const { copy } = useClipboard() // 初始化 copy 到粘贴板
|
||||||
// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
|
||||||
const messageContainer: any = ref(null)
|
const messageContainer: any = ref(null)
|
||||||
const isScrolling = ref(false) //用于判断用户是否在滚动
|
const isScrolling = ref(false) //用于判断用户是否在滚动
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
|
const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
|
||||||
const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
|
const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
|
||||||
|
|
||||||
|
@ -95,17 +93,20 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { list } = toRefs(props) // 消息列表
|
||||||
|
|
||||||
|
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits
|
||||||
|
|
||||||
// ============ 处理对话滚动 ==============
|
// ============ 处理对话滚动 ==============
|
||||||
|
|
||||||
|
/** 滚动到底部 */
|
||||||
const scrollToBottom = async (isIgnore?: boolean) => {
|
const scrollToBottom = async (isIgnore?: boolean) => {
|
||||||
await nextTick(() => {
|
// 注意要使用 nextTick 以免获取不到 dom
|
||||||
// TODO @fan:中文写作习惯,中英文之间要有空格;另外,nextick 哈,idea 如果有绿色波兰线,可以关注下
|
await nextTick()
|
||||||
//注意要使用nexttick以免获取不到dom
|
if (isIgnore || !isScrolling.value) {
|
||||||
if (isIgnore || !isScrolling.value) {
|
messageContainer.value.scrollTop =
|
||||||
messageContainer.value.scrollTop =
|
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
|
||||||
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
@ -122,75 +123,48 @@ function handleScroll() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 回到底部 */
|
||||||
* 复制
|
|
||||||
*/
|
|
||||||
const noCopy = async (content) => {
|
|
||||||
copy(content)
|
|
||||||
ElMessage({
|
|
||||||
message: '复制成功!',
|
|
||||||
type: 'success'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除
|
|
||||||
*/
|
|
||||||
const onDelete = async (id) => {
|
|
||||||
// 删除 message
|
|
||||||
await ChatMessageApi.delete(id)
|
|
||||||
ElMessage({
|
|
||||||
message: '删除成功!',
|
|
||||||
type: 'success'
|
|
||||||
})
|
|
||||||
// 回调
|
|
||||||
emits('onDeleteSuccess')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新
|
|
||||||
*/
|
|
||||||
const onRefresh = async (message: ChatMessageVO) => {
|
|
||||||
emits('onRefresh', message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑
|
|
||||||
*/
|
|
||||||
const onEdit = async (message: ChatMessageVO) => {
|
|
||||||
emits('onEdit', message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 回到底部
|
|
||||||
*/
|
|
||||||
const handleGoBottom = async () => {
|
const handleGoBottom = async () => {
|
||||||
const scrollContainer = messageContainer.value
|
const scrollContainer = messageContainer.value
|
||||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 回到顶部 */
|
||||||
* 回到顶部
|
|
||||||
*/
|
|
||||||
const handlerGoTop = async () => {
|
const handlerGoTop = async () => {
|
||||||
const scrollContainer = messageContainer.value
|
const scrollContainer = messageContainer.value
|
||||||
scrollContainer.scrollTop = 0
|
scrollContainer.scrollTop = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 list
|
defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用
|
||||||
// TODO @fan:这个木有,是不是删除啦
|
|
||||||
const { list, conversationId } = toRefs(props)
|
|
||||||
watch(list, async (newValue, oldValue) => {
|
|
||||||
console.log('watch list', list)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 提供方法给 parent 调用
|
// ============ 处理消息操作 ==============
|
||||||
defineExpose({ scrollToBottom, handlerGoTop })
|
|
||||||
|
|
||||||
// 定义 emits
|
/** 复制 */
|
||||||
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit'])
|
const copyContent = async (content) => {
|
||||||
|
await copy(content)
|
||||||
|
message.success('复制成功!')
|
||||||
|
}
|
||||||
|
|
||||||
// onMounted
|
/** 删除 */
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
messageContainer.value.addEventListener('scroll', handleScroll)
|
messageContainer.value.addEventListener('scroll', handleScroll)
|
||||||
})
|
})
|
||||||
|
@ -199,15 +173,7 @@ onMounted(async () => {
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.message-container {
|
.message-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
//top: 0;
|
|
||||||
//bottom: 0;
|
|
||||||
//left: 0;
|
|
||||||
//right: 0;
|
|
||||||
//width: 100%;
|
|
||||||
//height: 100%;
|
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
//padding: 0 15px;
|
|
||||||
//z-index: -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中间
|
// 中间
|
||||||
|
@ -231,11 +197,6 @@ onMounted(async () => {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
|
||||||
//height: 170px;
|
|
||||||
//width: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -272,7 +233,6 @@ onMounted(async () => {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: inline;
|
display: inline;
|
||||||
background-color: #267fff;
|
background-color: #267fff;
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 0 0 1px #267fff;
|
box-shadow: 0 0 0 1px #267fff;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
|
@ -1,31 +1,35 @@
|
||||||
|
<!-- 消息列表为空时,展示 prompt 列表 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-empty">
|
<div class="chat-empty">
|
||||||
|
<!-- title -->
|
||||||
<!-- title -->
|
|
||||||
<div class="center-container">
|
<div class="center-container">
|
||||||
<div class="title">芋艿 AI</div>
|
<div class="title">芋道 AI</div>
|
||||||
<div class="role-list">
|
<div class="role-list">
|
||||||
<div class="role-item" v-for="prompt in promptList" :key="prompt.prompt" @click="handlerPromptClick(prompt)">
|
<div
|
||||||
{{prompt.prompt}}
|
class="role-item"
|
||||||
|
v-for="prompt in promptList"
|
||||||
|
:key="prompt.prompt"
|
||||||
|
@click="handlerPromptClick(prompt)"
|
||||||
|
>
|
||||||
|
{{ prompt.prompt }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const promptList = [
|
||||||
const promptList = ref<any[]>() // 角色列表
|
|
||||||
promptList.value = [
|
|
||||||
{
|
{
|
||||||
"prompt": "今天气怎么样?",
|
prompt: '今天气怎么样?'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"prompt": "写一首好听的诗歌?",
|
prompt: '写一首好听的诗歌?'
|
||||||
}
|
}
|
||||||
]
|
] // prompt 列表
|
||||||
|
|
||||||
const emits = defineEmits(['onPrompt'])
|
const emits = defineEmits(['onPrompt'])
|
||||||
|
|
||||||
|
/** 选中 prompt 点击 */
|
||||||
const handlerPromptClick = async ({ prompt }) => {
|
const handlerPromptClick = async ({ prompt }) => {
|
||||||
emits('onPrompt', prompt)
|
emits('onPrompt', prompt)
|
||||||
}
|
}
|
|
@ -1,25 +1,21 @@
|
||||||
<!-- message 新增对话 -->
|
<!-- 无聊天对话时,在 message 区域,可以新增对话 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="new-chat" >
|
<div class="new-chat">
|
||||||
<div class="box-center">
|
<div class="box-center">
|
||||||
<div class="tip">点击下方按钮,开始你的对话吧</div>
|
<div class="tip">点击下方按钮,开始你的对话吧</div>
|
||||||
<div class="btns"><el-button type="primary" round @click="handlerNewChat">新建对话</el-button></div>
|
<div class="btns">
|
||||||
|
<el-button type="primary" round @click="handlerNewChat">新建对话</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const emits = defineEmits(['onNewConversation'])
|
||||||
|
|
||||||
// 定义钩子
|
/** 新建 conversation 聊天对话 */
|
||||||
const emits = defineEmits(['onNewChat'])
|
const handlerNewChat = () => {
|
||||||
|
emits('onNewConversation')
|
||||||
/**
|
|
||||||
* 新建 chat
|
|
||||||
*/
|
|
||||||
const handlerNewChat = async () => {
|
|
||||||
await emits('onNewChat')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.new-chat {
|
.new-chat {
|
|
@ -1,13 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="category-list">
|
<div class="category-list">
|
||||||
<div class="category" v-for="(category) in categoryList" :key="category">
|
<div class="category" v-for="category in categoryList" :key="category">
|
||||||
<el-button plain round size="small" v-if="category !== active" @click="handleCategoryClick(category)">{{ category }}</el-button>
|
<el-button
|
||||||
<el-button plain round size="small" v-else type="primary" @click="handleCategoryClick(category)">{{ category }}</el-button>
|
plain
|
||||||
|
round
|
||||||
|
size="small"
|
||||||
|
:type="category === active ? 'primary' : ''"
|
||||||
|
@click="handleCategoryClick(category)"
|
||||||
|
>
|
||||||
|
{{ category }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {PropType} from "vue";
|
import { PropType } from 'vue'
|
||||||
|
|
||||||
// 定义属性
|
// 定义属性
|
||||||
defineProps({
|
defineProps({
|
||||||
|
@ -25,11 +32,10 @@ defineProps({
|
||||||
// 定义回调
|
// 定义回调
|
||||||
const emits = defineEmits(['onCategoryClick'])
|
const emits = defineEmits(['onCategoryClick'])
|
||||||
|
|
||||||
// 处理分类点击事件
|
/** 处理分类点击事件 */
|
||||||
const handleCategoryClick = async (category) => {
|
const handleCategoryClick = async (category: string) => {
|
||||||
emits('onCategoryClick', category)
|
emits('onCategoryClick', category)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.category-list {
|
.category-list {
|
|
@ -1,32 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
|
<div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
|
||||||
<div class="card-item" v-for="role in roleList" :key="role.id">
|
<div class="card-item" v-for="role in roleList" :key="role.id">
|
||||||
<el-card class="card" body-class="card-body">
|
<el-card class="card" body-class="card-body">
|
||||||
<!-- 更多 -->
|
<!-- 更多操作 -->
|
||||||
<div class="more-container" v-if="showMore">
|
<div class="more-container" v-if="showMore">
|
||||||
<el-dropdown @command="handleMoreClick">
|
<el-dropdown @command="handleMoreClick">
|
||||||
<span class="el-dropdown-link">
|
<span class="el-dropdown-link">
|
||||||
<el-button type="text" >
|
<el-button type="text">
|
||||||
<el-icon><More /></el-icon>
|
<el-icon><More /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
<!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item :command="['edit', role]" >
|
<el-dropdown-item :command="['edit', role]">
|
||||||
<el-icon><EditPen /></el-icon>编辑
|
<Icon icon="ep:edit" color="#787878" />编辑
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item :command="['delete', role]" style="color: red;" >
|
<el-dropdown-item :command="['delete', role]" style="color: red">
|
||||||
<el-icon><Delete /></el-icon>
|
<Icon icon="ep:delete" color="red" />删除
|
||||||
<span>删除</span>
|
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
<!-- 头像 -->
|
<!-- 角色信息 -->
|
||||||
<div>
|
<div>
|
||||||
<img class="avatar" :src="role.avatar"/>
|
<img class="avatar" :src="role.avatar" />
|
||||||
</div>
|
</div>
|
||||||
<div class="right-container">
|
<div class="right-container">
|
||||||
<div class="content-container">
|
<div class="content-container">
|
||||||
|
@ -44,8 +42,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ChatRoleVO} from '@/api/ai/model/chatRole'
|
import {ChatRoleVO} from '@/api/ai/model/chatRole'
|
||||||
import {PropType, ref} from "vue";
|
import {PropType, ref} from 'vue'
|
||||||
import {Delete, EditPen, More} from "@element-plus/icons-vue";
|
import {More} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const tabsRef = ref<any>() // tabs ref
|
const tabsRef = ref<any>() // tabs ref
|
||||||
|
|
||||||
|
@ -65,10 +63,11 @@ const props = defineProps({
|
||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义钩子
|
// 定义钩子
|
||||||
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
|
const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
|
||||||
|
|
||||||
// more 点击
|
/** 操作:编辑、删除 */
|
||||||
const handleMoreClick = async (data) => {
|
const handleMoreClick = async (data) => {
|
||||||
const type = data[0]
|
const type = data[0]
|
||||||
const role = data[1]
|
const role = data[1]
|
||||||
|
@ -79,28 +78,20 @@ const handleMoreClick = async (data) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用
|
/** 选中 */
|
||||||
const handleUseClick = (role) => {
|
const handleUseClick = (role) => {
|
||||||
emits('onUse', role)
|
emits('onUse', role)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 滚动 */
|
||||||
const handleTabsScroll = async () => {
|
const handleTabsScroll = async () => {
|
||||||
if (tabsRef.value) {
|
if (tabsRef.value) {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
|
const { scrollTop, scrollHeight, clientHeight } = tabsRef.value
|
||||||
console.log('scrollTop', scrollTop)
|
|
||||||
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
|
if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
|
||||||
console.log('分页')
|
|
||||||
// page.value++;
|
|
||||||
// fetchData(page.value);
|
|
||||||
await emits('onPage')
|
await emits('onPage')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
console.log('props', props.roleList)
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@ -114,11 +105,9 @@ onMounted(() => {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|
||||||
// 卡片列表
|
// 卡片列表
|
||||||
.card-list {
|
.card-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -180,9 +169,6 @@ onMounted(() => {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
|
@ -2,8 +2,8 @@
|
||||||
<template>
|
<template>
|
||||||
<el-container class="role-container">
|
<el-container class="role-container">
|
||||||
<ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
|
<ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
|
||||||
<!-- header -->
|
<!-- header -->
|
||||||
<Header title="角色仓库" style="position: relative" />
|
<RoleHeader title="角色仓库" class="relative" />
|
||||||
<!-- main -->
|
<!-- main -->
|
||||||
<el-main class="role-main">
|
<el-main class="role-main">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
|
@ -18,20 +18,17 @@
|
||||||
@change="getActiveTabsRole"
|
@change="getActiveTabsRole"
|
||||||
/>
|
/>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="activeRole == 'my-role'"
|
v-if="activeTab == 'my-role'"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handlerAddRole"
|
@click="handlerAddRole"
|
||||||
style="margin-left: 20px"
|
class="ml-20px"
|
||||||
>
|
>
|
||||||
<!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
|
<Icon icon="ep:user" style="margin-right: 5px;" />
|
||||||
<el-icon>
|
|
||||||
<User />
|
|
||||||
</el-icon>
|
|
||||||
添加角色
|
添加角色
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<!-- tabs -->
|
<!-- tabs -->
|
||||||
<el-tabs v-model="activeRole" class="tabs" @tab-click="handleTabsClick">
|
<el-tabs v-model="activeTab" class="tabs" @tab-click="handleTabsClick">
|
||||||
<el-tab-pane class="role-pane" label="我的角色" name="my-role">
|
<el-tab-pane class="role-pane" label="我的角色" name="my-role">
|
||||||
<RoleList
|
<RoleList
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
@ -41,7 +38,7 @@
|
||||||
@on-edit="handlerCardEdit"
|
@on-edit="handlerCardEdit"
|
||||||
@on-use="handlerCardUse"
|
@on-use="handlerCardUse"
|
||||||
@on-page="handlerCardPage('my')"
|
@on-page="handlerCardPage('my')"
|
||||||
style="margin-top: 20px"
|
class="mt-20px"
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="公共角色" name="public-role">
|
<el-tab-pane label="公共角色" name="public-role">
|
||||||
|
@ -57,7 +54,7 @@
|
||||||
@on-edit="handlerCardEdit"
|
@on-edit="handlerCardEdit"
|
||||||
@on-use="handlerCardUse"
|
@on-use="handlerCardUse"
|
||||||
@on-page="handlerCardPage('public')"
|
@on-page="handlerCardPage('public')"
|
||||||
style="margin-top: 20px"
|
class="mt-20px"
|
||||||
loading
|
loading
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
@ -67,28 +64,31 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import {ref} from 'vue'
|
||||||
import Header from '@/views/ai/chat/components/Header.vue'
|
import RoleHeader from './RoleHeader.vue'
|
||||||
import RoleList from './RoleList.vue'
|
import RoleList from './RoleList.vue'
|
||||||
import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
|
import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
|
||||||
import RoleCategoryList from './RoleCategoryList.vue'
|
import RoleCategoryList from './RoleCategoryList.vue'
|
||||||
import { ChatRoleApi, ChatRolePageReqVO, ChatRoleVO } from '@/api/ai/model/chatRole'
|
import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole'
|
||||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
|
||||||
import { TabsPaneContext } from 'element-plus'
|
import {Search} from '@element-plus/icons-vue'
|
||||||
import { Search, User } from '@element-plus/icons-vue'
|
import {TabsPaneContext} from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter() // 路由对象
|
const router = useRouter() // 路由对象
|
||||||
|
|
||||||
// 属性定义
|
// 属性定义
|
||||||
const loading = ref<boolean>(false) // 加载中
|
const loading = ref<boolean>(false) // 加载中
|
||||||
const activeRole = ref<string>('my-role') // 选中的角色 TODO @fan:是不是叫 activeTab 会更明确一点哈。选中的角色,会以为是某个角色
|
const activeTab = ref<string>('my-role') // 选中的角色 Tab
|
||||||
const search = ref<string>('') // 加载中
|
const search = ref<string>('') // 加载中
|
||||||
// TODO @fan:要不 myPage、pubPage,搞成类似 const queryParams = reactive({ ,分别搞成两个大的参数哈?
|
const myRoleParams = reactive({
|
||||||
const myPageNo = ref<number>(1) // my 分页下标
|
pageNo: 1,
|
||||||
const myPageSize = ref<number>(50) // my 分页大小
|
pageSize: 50
|
||||||
|
})
|
||||||
const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小
|
const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小
|
||||||
const publicPageNo = ref<number>(1) // public 分页下标
|
const publicRoleParams = reactive({
|
||||||
const publicPageSize = ref<number>(50) // public 分页大小
|
pageNo: 1,
|
||||||
|
pageSize: 50
|
||||||
|
})
|
||||||
const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小
|
const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小
|
||||||
const activeCategory = ref<string>('全部') // 选择中的分类
|
const activeCategory = ref<string>('全部') // 选择中的分类
|
||||||
const categoryList = ref<string[]>([]) // 角色分类类别
|
const categoryList = ref<string[]>([]) // 角色分类类别
|
||||||
|
@ -96,7 +96,7 @@ const categoryList = ref<string[]>([]) // 角色分类类别
|
||||||
/** tabs 点击 */
|
/** tabs 点击 */
|
||||||
const handleTabsClick = async (tab: TabsPaneContext) => {
|
const handleTabsClick = async (tab: TabsPaneContext) => {
|
||||||
// 设置切换状态
|
// 设置切换状态
|
||||||
activeRole.value = tab.paneName + ''
|
activeTab.value = tab.paneName + ''
|
||||||
// 切换的时候重新加载数据
|
// 切换的时候重新加载数据
|
||||||
await getActiveTabsRole()
|
await getActiveTabsRole()
|
||||||
}
|
}
|
||||||
|
@ -104,12 +104,11 @@ const handleTabsClick = async (tab: TabsPaneContext) => {
|
||||||
/** 获取 my role 我的角色 */
|
/** 获取 my role 我的角色 */
|
||||||
const getMyRole = async (append?: boolean) => {
|
const getMyRole = async (append?: boolean) => {
|
||||||
const params: ChatRolePageReqVO = {
|
const params: ChatRolePageReqVO = {
|
||||||
pageNo: myPageNo.value,
|
...myRoleParams,
|
||||||
pageSize: myPageSize.value,
|
|
||||||
name: search.value,
|
name: search.value,
|
||||||
publicStatus: false
|
publicStatus: false
|
||||||
}
|
}
|
||||||
const { total, list } = await ChatRoleApi.getMyPage(params)
|
const { list } = await ChatRoleApi.getMyPage(params)
|
||||||
if (append) {
|
if (append) {
|
||||||
myRoleList.value.push.apply(myRoleList.value, list)
|
myRoleList.value.push.apply(myRoleList.value, list)
|
||||||
} else {
|
} else {
|
||||||
|
@ -120,8 +119,7 @@ const getMyRole = async (append?: boolean) => {
|
||||||
/** 获取 public role 公共角色 */
|
/** 获取 public role 公共角色 */
|
||||||
const getPublicRole = async (append?: boolean) => {
|
const getPublicRole = async (append?: boolean) => {
|
||||||
const params: ChatRolePageReqVO = {
|
const params: ChatRolePageReqVO = {
|
||||||
pageNo: publicPageNo.value,
|
...publicRoleParams,
|
||||||
pageSize: publicPageSize.value,
|
|
||||||
category: activeCategory.value === '全部' ? '' : activeCategory.value,
|
category: activeCategory.value === '全部' ? '' : activeCategory.value,
|
||||||
name: search.value,
|
name: search.value,
|
||||||
publicStatus: true
|
publicStatus: true
|
||||||
|
@ -136,20 +134,18 @@ const getPublicRole = async (append?: boolean) => {
|
||||||
|
|
||||||
/** 获取选中的 tabs 角色 */
|
/** 获取选中的 tabs 角色 */
|
||||||
const getActiveTabsRole = async () => {
|
const getActiveTabsRole = async () => {
|
||||||
if (activeRole.value === 'my-role') {
|
if (activeTab.value === 'my-role') {
|
||||||
myPageNo.value = 1
|
myRoleParams.pageNo = 1
|
||||||
await getMyRole()
|
await getMyRole()
|
||||||
} else {
|
} else {
|
||||||
publicPageNo.value = 1
|
publicRoleParams.pageNo = 1
|
||||||
await getPublicRole()
|
await getPublicRole()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取角色分类列表 */
|
/** 获取角色分类列表 */
|
||||||
const getRoleCategoryList = async () => {
|
const getRoleCategoryList = async () => {
|
||||||
const res = await ChatRoleApi.getCategoryList()
|
categoryList.value = ['全部', ...(await ChatRoleApi.getCategoryList())]
|
||||||
const defaultRole = ['全部']
|
|
||||||
categoryList.value = [...defaultRole, ...res]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 处理分类点击 */
|
/** 处理分类点击 */
|
||||||
|
@ -165,6 +161,10 @@ const formRef = ref()
|
||||||
const handlerAddRole = async () => {
|
const handlerAddRole = async () => {
|
||||||
formRef.value.open('my-create', null, '添加角色')
|
formRef.value.open('my-create', null, '添加角色')
|
||||||
}
|
}
|
||||||
|
/** 编辑角色 */
|
||||||
|
const handlerCardEdit = async (role) => {
|
||||||
|
formRef.value.open('my-update', role.id, '编辑角色')
|
||||||
|
}
|
||||||
|
|
||||||
/** 添加角色成功 */
|
/** 添加角色成功 */
|
||||||
const handlerAddRoleSuccess = async (e) => {
|
const handlerAddRoleSuccess = async (e) => {
|
||||||
|
@ -172,28 +172,22 @@ const handlerAddRoleSuccess = async (e) => {
|
||||||
await getActiveTabsRole()
|
await getActiveTabsRole()
|
||||||
}
|
}
|
||||||
|
|
||||||
// card 删除
|
/** 删除角色 */
|
||||||
const handlerCardDelete = async (role) => {
|
const handlerCardDelete = async (role) => {
|
||||||
await ChatRoleApi.deleteMy(role.id)
|
await ChatRoleApi.deleteMy(role.id)
|
||||||
// 刷新数据
|
// 刷新数据
|
||||||
await getActiveTabsRole()
|
await getActiveTabsRole()
|
||||||
}
|
}
|
||||||
|
|
||||||
// card 编辑
|
/** 角色分页:获取下一页 */
|
||||||
const handlerCardEdit = async (role) => {
|
|
||||||
formRef.value.open('my-update', role.id, '编辑角色')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** card 分页:获取下一页 */
|
|
||||||
const handlerCardPage = async (type) => {
|
const handlerCardPage = async (type) => {
|
||||||
console.log('handlerCardPage', type)
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
publicPageNo.value++
|
publicRoleParams.pageNo++
|
||||||
await getPublicRole(true)
|
await getPublicRole(true)
|
||||||
} else {
|
} else {
|
||||||
myPageNo.value++
|
myRoleParams.pageNo++
|
||||||
await getMyRole(true)
|
await getMyRole(true)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -208,10 +202,10 @@ const handlerCardUse = async (role) => {
|
||||||
roleId: role.id
|
roleId: role.id
|
||||||
} as unknown as ChatConversationVO
|
} as unknown as ChatConversationVO
|
||||||
const conversationId = await ChatConversationApi.createChatConversationMy(data)
|
const conversationId = await ChatConversationApi.createChatConversationMy(data)
|
||||||
|
|
||||||
// 2. 跳转页面
|
// 2. 跳转页面
|
||||||
// TODO @fan:最好用 name,后续可能会改~~~
|
|
||||||
await router.push({
|
await router.push({
|
||||||
path: `/ai/chat`,
|
name: 'AiChat',
|
||||||
query: {
|
query: {
|
||||||
conversationId: conversationId
|
conversationId: conversationId
|
||||||
}
|
}
|
||||||
|
@ -225,15 +219,14 @@ onMounted(async () => {
|
||||||
// 获取 role 数据
|
// 获取 role 数据
|
||||||
await getActiveTabsRole()
|
await getActiveTabsRole()
|
||||||
})
|
})
|
||||||
// TODO @fan:css 是不是可以融合到 scss 里面呀?
|
|
||||||
</script>
|
</script>
|
||||||
<style lang="css">
|
<!-- 覆盖 element ui css -->
|
||||||
|
<style lang="scss">
|
||||||
.el-tabs__content {
|
.el-tabs__content {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-tabs__nav-scroll {
|
.el-tabs__nav-scroll {
|
||||||
margin: 10px 20px;
|
margin: 10px 20px;
|
||||||
}
|
}
|
|
@ -1,33 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<el-container class="ai-layout">
|
<el-container class="ai-layout">
|
||||||
<!-- 左侧:对话列表 -->
|
<!-- 左侧:对话列表 -->
|
||||||
<Conversation
|
<ConversationList
|
||||||
:active-id="activeConversationId"
|
:active-id="activeConversationId"
|
||||||
ref="conversationRef"
|
ref="conversationListRef"
|
||||||
@onConversationCreate="handleConversationCreate"
|
@on-conversation-create="handleConversationCreateSuccess"
|
||||||
@onConversationClick="handleConversationClick"
|
@on-conversation-click="handleConversationClick"
|
||||||
@onConversationClear="handlerConversationClear"
|
@on-conversation-clear="handleConversationClear"
|
||||||
@onConversationDelete="handlerConversationDelete"
|
@on-conversation-delete="handlerConversationDelete"
|
||||||
/>
|
/>
|
||||||
<!-- 右侧:对话详情 -->
|
<!-- 右侧:对话详情 -->
|
||||||
<el-container class="detail-container">
|
<el-container class="detail-container">
|
||||||
<el-header class="header">
|
<el-header class="header">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
|
{{ activeConversation?.title ? activeConversation?.title : '对话' }}
|
||||||
<span v-if="list.length">({{ list.length }})</span>
|
<span v-if="activeMessageList.length">({{ activeMessageList.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btns" v-if="activeConversation">
|
<div class="btns" v-if="activeConversation">
|
||||||
<el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
|
<el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
|
||||||
<span v-html="activeConversation?.modelName"></span>
|
<span v-html="activeConversation?.modelName"></span>
|
||||||
<Icon icon="ep:setting" style="margin-left: 10px" />
|
<Icon icon="ep:setting" class="ml-10px" />
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" class="btn" @click="handlerMessageClear">
|
<el-button size="small" class="btn" @click="handlerMessageClear">
|
||||||
<!-- TODO @fan:style 部分,可以考虑用 unocss 替代 -->
|
<Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
|
||||||
<img src="@/assets/ai/clear.svg" style="height: 14px" />
|
</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>
|
</el-button>
|
||||||
<!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
|
|
||||||
<el-button size="small" :icon="Download" class="btn" />
|
|
||||||
<el-button size="small" :icon="Top" class="btn" @click="handlerGoTop" />
|
|
||||||
</div>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
|
|
||||||
|
@ -35,20 +37,27 @@
|
||||||
<el-main class="main-container">
|
<el-main class="main-container">
|
||||||
<div>
|
<div>
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
<MessageLoading v-if="listLoading" />
|
<!-- 情况一:消息加载中 -->
|
||||||
<MessageNewChat v-if="!activeConversation" @on-new-chat="handlerNewChat" />
|
<MessageLoading v-if="activeMessageListLoading" />
|
||||||
<ChatEmpty
|
<!-- 情况二:无聊天对话时 -->
|
||||||
v-if="!listLoading && messageList.length === 0 && activeConversation"
|
<MessageNewConversation
|
||||||
@on-prompt="doSend"
|
v-if="!activeConversation"
|
||||||
|
@on-new-conversation="handleConversationCreate"
|
||||||
/>
|
/>
|
||||||
<Message
|
<!-- 情况三:消息列表为空 -->
|
||||||
v-if="!listLoading && messageList.length > 0"
|
<MessageListEmpty
|
||||||
|
v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation"
|
||||||
|
@on-prompt="doSendMessage"
|
||||||
|
/>
|
||||||
|
<!-- 情况四:消息列表不为空 -->
|
||||||
|
<MessageList
|
||||||
|
v-if="!activeMessageListLoading && messageList.length > 0"
|
||||||
ref="messageRef"
|
ref="messageRef"
|
||||||
:conversation="activeConversation"
|
:conversation="activeConversation"
|
||||||
:list="messageList"
|
:list="messageList"
|
||||||
@on-delete-success="handlerMessageDelete"
|
@on-delete-success="handleMessageDelete"
|
||||||
@on-edit="handlerMessageEdit"
|
@on-edit="handleMessageEdit"
|
||||||
@on-refresh="handlerMessageRefresh"
|
@on-refresh="handleMessageRefresh"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -60,8 +69,8 @@
|
||||||
<textarea
|
<textarea
|
||||||
class="prompt-input"
|
class="prompt-input"
|
||||||
v-model="prompt"
|
v-model="prompt"
|
||||||
@keydown="onSend"
|
@keydown="handleSendByKeydown"
|
||||||
@input="onPromptInput"
|
@input="handlePromptInput"
|
||||||
@compositionstart="onCompositionstart"
|
@compositionstart="onCompositionstart"
|
||||||
@compositionend="onCompositionend"
|
@compositionend="onCompositionend"
|
||||||
placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
|
placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
|
||||||
|
@ -69,12 +78,12 @@
|
||||||
<div class="prompt-btns">
|
<div class="prompt-btns">
|
||||||
<div>
|
<div>
|
||||||
<el-switch v-model="enableContext" />
|
<el-switch v-model="enableContext" />
|
||||||
<span style="font-size: 14px; color: #8f8f8f">上下文</span>
|
<span class="ml-5px text-14px text-#8f8f8f">上下文</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="default"
|
size="default"
|
||||||
@click="onSendBtn"
|
@click="handleSendByButton"
|
||||||
:loading="conversationInProgress"
|
:loading="conversationInProgress"
|
||||||
v-if="conversationInProgress == false"
|
v-if="conversationInProgress == false"
|
||||||
>
|
>
|
||||||
|
@ -93,148 +102,251 @@
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
<!-- ========= 额外组件 ========== -->
|
|
||||||
<!-- 更新对话 Form -->
|
<!-- 更新对话 Form -->
|
||||||
<ChatConversationUpdateForm
|
<ConversationUpdateForm
|
||||||
ref="chatConversationUpdateFormRef"
|
ref="conversationUpdateFormRef"
|
||||||
@success="handlerTitleSuccess"
|
@success="handleConversationUpdateSuccess"
|
||||||
/>
|
/>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// TODO @fan:是不是把 index.vue 相关的,在这里新建一个 index 目录,然后挪进去哈。因为 /ai/chat 还会有其它功能。例如说,现在的 /ai/chat/manager 管理
|
|
||||||
import Conversation from './Conversation.vue'
|
|
||||||
import Message from './Message.vue'
|
|
||||||
import ChatEmpty from './ChatEmpty.vue'
|
|
||||||
import MessageLoading from './MessageLoading.vue'
|
|
||||||
import MessageNewChat from './MessageNewChat.vue'
|
|
||||||
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
|
||||||
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
|
||||||
import ChatConversationUpdateForm from '@/views/ai/chat/components/ChatConversationUpdateForm.vue'
|
import ConversationList from './components/conversation/ConversationList.vue'
|
||||||
import { Download, Top } from '@element-plus/icons-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 route = useRoute() // 路由
|
||||||
const message = useMessage() // 消息弹窗
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
// ref 属性定义
|
// 聊天对话
|
||||||
const activeConversationId = ref<string | null>(null) // 选中的对话编号
|
const conversationListRef = ref()
|
||||||
|
const activeConversationId = ref<number | null>(null) // 选中的对话编号
|
||||||
const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
|
const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
|
||||||
const conversationInProgress = ref(false) // 对话进行中
|
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 conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
|
||||||
const inputTimeout = ref<any>() // 处理输入中回车的定时器
|
const inputTimeout = ref<any>() // 处理输入中回车的定时器
|
||||||
const prompt = ref<string>() // prompt
|
const prompt = ref<string>() // prompt
|
||||||
const enableContext = ref<boolean>(true) // 是否开启上下文
|
const enableContext = ref<boolean>(true) // 是否开启上下文
|
||||||
|
// 接收 Stream 消息
|
||||||
|
const receiveMessageFullText = ref('')
|
||||||
|
const receiveMessageDisplayedText = ref('')
|
||||||
|
|
||||||
// TODO @fan:这几个变量,可以注释在补下哈;另外,fullText 可以明确是生成中的消息 Text,这样更容易理解哈;
|
// =========== 【聊天对话】相关 ===========
|
||||||
const fullText = ref('')
|
|
||||||
const displayedText = ref('')
|
|
||||||
const textSpeed = ref<number>(50) // Typing speed in milliseconds
|
|
||||||
const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
|
|
||||||
|
|
||||||
// chat message 列表
|
/** 获取对话信息 */
|
||||||
// TODO @fan:list、listLoading、listLoadingTime 不能体现出来是消息列表,是不是可以变量再优化下
|
const getConversation = async (id: number | null) => {
|
||||||
const list = ref<ChatMessageVO[]>([]) // 列表的数据
|
if (!id) {
|
||||||
const listLoading = ref<boolean>(false) // 是否加载中
|
return
|
||||||
const listLoadingTime = ref<any>() // time 定时器,如果加载速度很快,就不进入加载中
|
}
|
||||||
|
const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
|
||||||
|
if (!conversation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeConversation.value = conversation
|
||||||
|
activeConversationId.value = conversation.id
|
||||||
|
}
|
||||||
|
|
||||||
// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
|
/**
|
||||||
const messageRef = ref()
|
* 点击某个对话
|
||||||
const conversationRef = ref()
|
*
|
||||||
const isComposing = ref(false) // 判断用户是否在输入
|
* @param conversation 选中的对话
|
||||||
|
* @return 是否切换成功
|
||||||
|
*/
|
||||||
|
const handleConversationClick = async (conversation: ChatConversationVO) => {
|
||||||
|
// 对话进行中,不允许切换
|
||||||
|
if (conversationInProgress.value) {
|
||||||
|
message.alert('对话中,不允许切换!')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 默认 role 头像
|
// 更新选中的对话 id
|
||||||
const defaultRoleAvatar =
|
activeConversationId.value = conversation.id
|
||||||
'http://test.yudao.iocoder.cn/eaef5f41acb911dd718429a0702dcc3c61160d16e57ba1d543132fab58934f9f.png'
|
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 = []
|
||||||
|
}
|
||||||
|
|
||||||
// TODO @fan:这个方法,要不加个方法注释
|
/** 修改聊天对话 */
|
||||||
const textRoll = async () => {
|
const conversationUpdateFormRef = ref()
|
||||||
let index = 0
|
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 {
|
try {
|
||||||
// 只能执行一次
|
if (activeConversationId.value === null) {
|
||||||
if (textRoleRunning.value) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 设置状态
|
// Timer 定时器,如果加载速度很快,就不进入加载中
|
||||||
textRoleRunning.value = true
|
activeMessageListLoadingTimer.value = setTimeout(() => {
|
||||||
displayedText.value = ''
|
activeMessageListLoading.value = true
|
||||||
const task = async () => {
|
}, 60)
|
||||||
// 调整速度
|
|
||||||
const diff = (fullText.value.length - displayedText.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
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log('index < fullText.value.length', index < fullText.value.length, conversationInProgress.value)
|
// 获取消息列表
|
||||||
|
activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
|
||||||
|
activeConversationId.value
|
||||||
|
)
|
||||||
|
|
||||||
if (index < fullText.value.length) {
|
// 滚动到最下面
|
||||||
displayedText.value += fullText.value[index]
|
await nextTick()
|
||||||
index++
|
await scrollToBottom()
|
||||||
|
|
||||||
// 更新 message
|
|
||||||
const lastMessage = list.value[list.value.length - 1]
|
|
||||||
lastMessage.content = displayedText.value
|
|
||||||
// TODO @fan:ist.value?,还是 ist.value.length 哈?
|
|
||||||
list.value[list.value - 1] = lastMessage
|
|
||||||
// 滚动到住下面
|
|
||||||
await scrollToBottom()
|
|
||||||
// 重新设置任务
|
|
||||||
timer = setTimeout(task, textSpeed.value)
|
|
||||||
} else {
|
|
||||||
// 不是对话中可以结束
|
|
||||||
if (!conversationInProgress.value) {
|
|
||||||
textRoleRunning.value = false
|
|
||||||
clearTimeout(timer)
|
|
||||||
console.log('字体滚动退出!')
|
|
||||||
} else {
|
|
||||||
// 重新设置任务
|
|
||||||
timer = setTimeout(task, textSpeed.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let timer = setTimeout(task, textSpeed.value)
|
|
||||||
} finally {
|
} 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 []
|
||||||
|
})
|
||||||
|
|
||||||
function scrollToBottom(isIgnore?: boolean) {
|
/** 处理删除 message 消息 */
|
||||||
// isIgnore = isIgnore !== null ? isIgnore : false
|
const handleMessageDelete = () => {
|
||||||
nextTick(() => {
|
if (conversationInProgress.value) {
|
||||||
if (messageRef.value) {
|
message.alert('回答中,不能删除!')
|
||||||
messageRef.value.scrollToBottom(isIgnore)
|
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 = () => {
|
||||||
// TODO @fan:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
|
doSendMessage(prompt.value?.trim() as string)
|
||||||
const onCompositionstart = () => {
|
|
||||||
isComposing.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCompositionend = () => {
|
/** 处理 prompt 输入变化 */
|
||||||
// console.log('输入结束...')
|
const handlePromptInput = (event) => {
|
||||||
setTimeout(() => {
|
|
||||||
isComposing.value = false
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPromptInput = (event) => {
|
|
||||||
// 非输入法 输入设置为 true
|
// 非输入法 输入设置为 true
|
||||||
if (!isComposing.value) {
|
if (!isComposing.value) {
|
||||||
// 回车 event data 是 null
|
// 回车 event data 是 null
|
||||||
|
@ -252,106 +364,76 @@ const onPromptInput = (event) => {
|
||||||
isComposing.value = false
|
isComposing.value = false
|
||||||
}, 400)
|
}, 400)
|
||||||
}
|
}
|
||||||
|
// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
|
||||||
// ============== 对话消息相关 =================
|
const onCompositionstart = () => {
|
||||||
|
isComposing.value = true
|
||||||
/**
|
}
|
||||||
* 发送消息
|
const onCompositionend = () => {
|
||||||
*/
|
// console.log('输入结束...')
|
||||||
const onSend = async (event) => {
|
setTimeout(() => {
|
||||||
// 判断用户是否在输入
|
isComposing.value = false
|
||||||
if (isComposing.value) {
|
}, 200)
|
||||||
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 doSend(content)
|
|
||||||
event.preventDefault() // 防止默认的提交行为
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSendBtn = async () => {
|
/** 真正执行【发送】消息操作 */
|
||||||
await doSend(prompt.value?.trim() as string)
|
const doSendMessage = async (content: string) => {
|
||||||
}
|
// 校验
|
||||||
|
if (content.length < 1) {
|
||||||
const doSend = async (content: string) => {
|
message.error('发送失败,原因:内容为空!')
|
||||||
if (content.length < 2) {
|
|
||||||
// TODO @fan:这个 message.error(`上传文件大小不能超过${props.fileSize}MB!`) 可以替代,这种形式
|
|
||||||
ElMessage({
|
|
||||||
message: '请输入内容!',
|
|
||||||
type: 'error'
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO @fan:这个 message.error(`上传文件大小不能超过${props.fileSize}MB!`) 可以替代,这种形式
|
|
||||||
if (activeConversationId.value == null) {
|
if (activeConversationId.value == null) {
|
||||||
ElMessage({
|
message.error('还没创建对话,不能发送!')
|
||||||
message: '还没创建对话,不能发送!',
|
|
||||||
type: 'error'
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 清空输入框
|
// 清空输入框
|
||||||
prompt.value = ''
|
prompt.value = ''
|
||||||
// TODO @fan:idea 这里会报类型错误,是不是可以解决下哈
|
// 执行发送
|
||||||
const userMessage = {
|
await doSendMessageStream({
|
||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
content: content
|
content: content
|
||||||
} as ChatMessageVO
|
} as ChatMessageVO)
|
||||||
// stream
|
|
||||||
await doSendStream(userMessage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSendStream = async (userMessage: ChatMessageVO) => {
|
/** 真正执行【发送】消息操作 */
|
||||||
// 创建AbortController实例,以便中止请求
|
const doSendMessageStream = async (userMessage: ChatMessageVO) => {
|
||||||
|
// 创建 AbortController 实例,以便中止请求
|
||||||
conversationInAbortController.value = new AbortController()
|
conversationInAbortController.value = new AbortController()
|
||||||
// 标记对话进行中
|
// 标记对话进行中
|
||||||
conversationInProgress.value = true
|
conversationInProgress.value = true
|
||||||
// 设置为空
|
// 设置为空
|
||||||
fullText.value = ''
|
receiveMessageFullText.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 先添加两个假数据,等 stream 返回再替换
|
// 1.1 先添加两个假数据,等 stream 返回再替换
|
||||||
list.value.push({
|
activeMessageList.value.push({
|
||||||
id: -1,
|
id: -1,
|
||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: userMessage.content,
|
content: userMessage.content,
|
||||||
createTime: new Date()
|
createTime: new Date()
|
||||||
} as ChatMessageVO)
|
} as ChatMessageVO)
|
||||||
list.value.push({
|
activeMessageList.value.push({
|
||||||
id: -2,
|
id: -2,
|
||||||
conversationId: activeConversationId.value,
|
conversationId: activeConversationId.value,
|
||||||
type: 'system',
|
type: 'assistant',
|
||||||
content: '思考中...',
|
content: '思考中...',
|
||||||
createTime: new Date()
|
createTime: new Date()
|
||||||
} as ChatMessageVO)
|
} as ChatMessageVO)
|
||||||
// 滚动到最下面
|
// 1.2 滚动到最下面
|
||||||
// TODO @fan:可以 await nextTick();然后同步调用 scrollToBottom()
|
await nextTick()
|
||||||
nextTick(async () => {
|
await scrollToBottom() // 底部
|
||||||
await scrollToBottom()
|
// 1.3 开始滚动
|
||||||
})
|
|
||||||
// 开始滚动
|
|
||||||
textRoll()
|
textRoll()
|
||||||
// 发送 event stream
|
|
||||||
let isFirstMessage = true // TODO @fan:isFirstChunk 会更精准
|
// 2. 发送 event stream
|
||||||
ChatMessageApi.sendStream(
|
let isFirstChunk = true // 是否是第一个 chunk 消息段
|
||||||
userMessage.conversationId, // TODO 芋艿:这里可能要在优化;
|
await ChatMessageApi.sendChatMessageStream(
|
||||||
|
userMessage.conversationId,
|
||||||
userMessage.content,
|
userMessage.content,
|
||||||
conversationInAbortController.value,
|
conversationInAbortController.value,
|
||||||
enableContext.value,
|
enableContext.value,
|
||||||
async (res) => {
|
async (res) => {
|
||||||
console.log('res', res)
|
|
||||||
const { code, data, msg } = JSON.parse(res.data)
|
const { code, data, msg } = JSON.parse(res.data)
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
message.alert(`对话异常! ${msg}`)
|
message.alert(`对话异常! ${msg}`)
|
||||||
|
@ -363,42 +445,33 @@ const doSendStream = async (userMessage: ChatMessageVO) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 首次返回需要添加一个 message 到页面,后面的都是更新
|
// 首次返回需要添加一个 message 到页面,后面的都是更新
|
||||||
if (isFirstMessage) {
|
if (isFirstChunk) {
|
||||||
isFirstMessage = false
|
isFirstChunk = false
|
||||||
// 弹出两个 假数据
|
// 弹出两个假数据
|
||||||
list.value.pop()
|
activeMessageList.value.pop()
|
||||||
list.value.pop()
|
activeMessageList.value.pop()
|
||||||
// 更新返回的数据
|
// 更新返回的数据
|
||||||
list.value.push(data.send)
|
activeMessageList.value.push(data.send)
|
||||||
list.value.push(data.receive)
|
activeMessageList.value.push(data.receive)
|
||||||
}
|
}
|
||||||
// debugger
|
// debugger
|
||||||
fullText.value = fullText.value + data.receive.content
|
receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
|
||||||
// 滚动到最下面
|
// 滚动到最下面
|
||||||
await scrollToBottom()
|
await scrollToBottom()
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
message.alert(`对话异常! ${error}`)
|
message.alert(`对话异常! ${error}`)
|
||||||
// TODO @fan:是不是可以复用 stopStream 方法
|
stopStream()
|
||||||
// 标记对话结束
|
|
||||||
conversationInProgress.value = false
|
|
||||||
// 结束 stream 对话
|
|
||||||
conversationInAbortController.value.abort()
|
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// TODO @fan:是不是可以复用 stopStream 方法
|
stopStream()
|
||||||
// 标记对话结束
|
|
||||||
conversationInProgress.value = false
|
|
||||||
// 结束 stream 对话
|
|
||||||
conversationInAbortController.value.abort()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} finally {
|
} catch {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 停止 stream 流式调用 */
|
||||||
const stopStream = async () => {
|
const stopStream = async () => {
|
||||||
console.log('stopStream...')
|
|
||||||
// tip:如果 stream 进行中的 message,就需要调用 controller 结束
|
// tip:如果 stream 进行中的 message,就需要调用 controller 结束
|
||||||
if (conversationInAbortController.value) {
|
if (conversationInAbortController.value) {
|
||||||
conversationInAbortController.value.abort()
|
conversationInAbortController.value.abort()
|
||||||
|
@ -407,217 +480,98 @@ const stopStream = async () => {
|
||||||
conversationInProgress.value = false
|
conversationInProgress.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== message 数据 =================
|
/** 编辑 message:设置为 prompt,可以再次编辑 */
|
||||||
|
const handleMessageEdit = (message: ChatMessageVO) => {
|
||||||
/** 消息列表 */
|
|
||||||
const messageList = computed(() => {
|
|
||||||
if (list.value.length > 0) {
|
|
||||||
return list.value
|
|
||||||
}
|
|
||||||
// 没有消息时,如果有 systemMessage 则展示它
|
|
||||||
// TODO add by 芋艿:这个消息下面,不能有复制、删除按钮
|
|
||||||
if (activeConversation.value?.systemMessage) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 0,
|
|
||||||
type: 'system',
|
|
||||||
content: activeConversation.value.systemMessage
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO @fan:一般情况下,项目方法注释用 /** */,啊哈,主要保持风格统一,= = 少占点行哈,
|
|
||||||
/**
|
|
||||||
* 获取 - message 列表
|
|
||||||
*/
|
|
||||||
const getMessageList = async () => {
|
|
||||||
try {
|
|
||||||
// time 定时器,如果加载速度很快,就不进入加载中
|
|
||||||
listLoadingTime.value = setTimeout(() => {
|
|
||||||
listLoading.value = true
|
|
||||||
}, 60)
|
|
||||||
if (activeConversationId.value === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 获取列表数据
|
|
||||||
list.value = await ChatMessageApi.messageList(activeConversationId.value)
|
|
||||||
// 滚动到最下面
|
|
||||||
await nextTick(() => {
|
|
||||||
// 滚动到最后
|
|
||||||
scrollToBottom()
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
// time 定时器,如果加载速度很快,就不进入加载中
|
|
||||||
if (listLoadingTime.value) {
|
|
||||||
clearTimeout(listLoadingTime.value)
|
|
||||||
}
|
|
||||||
// 加载结束
|
|
||||||
listLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 修改聊天对话 */
|
|
||||||
const chatConversationUpdateFormRef = ref()
|
|
||||||
const openChatConversationUpdateForm = async () => {
|
|
||||||
chatConversationUpdateFormRef.value.open(activeConversationId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 标题修改成功
|
|
||||||
*/
|
|
||||||
const handlerTitleSuccess = async () => {
|
|
||||||
// TODO 需要刷新 对话列表
|
|
||||||
await getConversation(activeConversationId.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 创建
|
|
||||||
*/
|
|
||||||
const handleConversationCreate = async () => {
|
|
||||||
// 创建新的对话,清空输入框
|
|
||||||
prompt.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 点击
|
|
||||||
*/
|
|
||||||
const handleConversationClick = async (conversation: ChatConversationVO) => {
|
|
||||||
// 对话进行中,不允许切换
|
|
||||||
if (conversationInProgress.value) {
|
|
||||||
await message.alert('对话中,不允许切换!')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新选中的对话 id
|
|
||||||
activeConversationId.value = conversation.id
|
|
||||||
activeConversation.value = conversation
|
|
||||||
// 处理进行中的对话
|
|
||||||
if (conversationInProgress.value) {
|
|
||||||
await stopStream()
|
|
||||||
}
|
|
||||||
// 刷新 message 列表
|
|
||||||
await getMessageList()
|
|
||||||
// 滚动底部
|
|
||||||
scrollToBottom(true)
|
|
||||||
// 清空输入框
|
|
||||||
prompt.value = ''
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 清理全部对话
|
|
||||||
*/
|
|
||||||
const handlerConversationClear = async () => {
|
|
||||||
// TODO @fan:需要加一个 对话进行中,不允许切换
|
|
||||||
activeConversationId.value = null
|
|
||||||
activeConversation.value = null
|
|
||||||
list.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 删除
|
|
||||||
*/
|
|
||||||
const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
|
|
||||||
// 删除的对话如果是当前选中的,那么就重置
|
|
||||||
if (activeConversationId.value === delConversation.id) {
|
|
||||||
await handlerConversationClear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 获取
|
|
||||||
*/
|
|
||||||
const getConversation = async (id: string | null) => {
|
|
||||||
if (!id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
|
|
||||||
if (conversation) {
|
|
||||||
activeConversation.value = conversation
|
|
||||||
activeConversationId.value = conversation.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对话 - 新建
|
|
||||||
*/
|
|
||||||
// TODO @fan:应该是 handleXXX,handler 是名词哈
|
|
||||||
const handlerNewChat = async () => {
|
|
||||||
// 创建对话
|
|
||||||
await conversationRef.value.createConversation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ message ===========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除 message
|
|
||||||
*/
|
|
||||||
const handlerMessageDelete = async () => {
|
|
||||||
if (conversationInProgress.value) {
|
|
||||||
message.alert('回答中,不能删除!')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 刷新 message
|
|
||||||
await getMessageList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑 message:设置为 prompt,可以再次编辑
|
|
||||||
*/
|
|
||||||
const handlerMessageEdit = async (message: ChatMessageVO) => {
|
|
||||||
prompt.value = message.content
|
prompt.value = message.content
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 刷新 message:基于指定消息,再次发起对话 */
|
||||||
* 刷新 message:基于指定消息,再次发起对话
|
const handleMessageRefresh = (message: ChatMessageVO) => {
|
||||||
*/
|
doSendMessage(message.content)
|
||||||
const handlerMessageRefresh = async (message: ChatMessageVO) => {
|
|
||||||
await doSend(message.content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============== 【消息滚动】相关 =============
|
||||||
* 回到顶部
|
|
||||||
*/
|
|
||||||
const handlerGoTop = async () => {
|
|
||||||
await messageRef.value.handlerGoTop()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** 滚动到 message 底部 */
|
||||||
* message 清除
|
const scrollToBottom = async (isIgnore?: boolean) => {
|
||||||
*/
|
await nextTick()
|
||||||
const handlerMessageClear = async () => {
|
if (messageRef.value) {
|
||||||
if (!activeConversationId.value) {
|
messageRef.value.scrollToBottom(isIgnore)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// TODO @fan:需要 try catch 下,不然点击取消会报异常
|
}
|
||||||
// 确认提示
|
|
||||||
await message.delConfirm('确认清空对话消息?')
|
/** 自提滚动效果 */
|
||||||
// 清空对话
|
const textRoll = async () => {
|
||||||
await ChatMessageApi.deleteByConversationId(activeConversationId.value as string)
|
let index = 0
|
||||||
// TODO @fan:是不是直接置空就好啦;
|
try {
|
||||||
// 刷新 message 列表
|
// 只能执行一次
|
||||||
await getMessageList()
|
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 () => {
|
onMounted(async () => {
|
||||||
// 设置当前对话 TODO 角色仓库过来的,自带 conversationId 需要选中
|
// 如果有 conversationId 参数,则默认选中
|
||||||
if (route.query.conversationId) {
|
if (route.query.conversationId) {
|
||||||
const id = route.query.conversationId as string
|
const id = route.query.conversationId as unknown as number
|
||||||
activeConversationId.value = id
|
activeConversationId.value = id
|
||||||
await getConversation(id)
|
await getConversation(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取列表数据
|
// 获取列表数据
|
||||||
listLoading.value = true
|
activeMessageListLoading.value = true
|
||||||
await getMessageList()
|
await getMessageList()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.ai-layout {
|
.ai-layout {
|
||||||
// TODO @范 这里height不能 100% 先这样临时处理
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -631,8 +585,7 @@ onMounted(async () => {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 10px;
|
padding: 10px 10px 0;
|
||||||
padding-top: 10px;
|
|
||||||
|
|
||||||
.btn-new-conversation {
|
.btn-new-conversation {
|
||||||
padding: 18px 0;
|
padding: 18px 0;
|
||||||
|
@ -771,8 +724,6 @@ onMounted(async () => {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
//width: 100%;
|
|
||||||
//height: 100%;
|
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -803,8 +754,7 @@ onMounted(async () => {
|
||||||
border: none;
|
border: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 0px 2px;
|
padding: 0 2px;
|
||||||
//padding: 5px 5px;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -815,7 +765,7 @@ onMounted(async () => {
|
||||||
.prompt-btns {
|
.prompt-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-bottom: 0px;
|
padding-bottom: 0;
|
||||||
padding-top: 5px;
|
padding-top: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -16,5 +16,5 @@ import ChatConversationList from './ChatConversationList.vue'
|
||||||
import ChatMessageList from './ChatMessageList.vue'
|
import ChatMessageList from './ChatMessageList.vue'
|
||||||
|
|
||||||
/** AI 聊天对话 列表 */
|
/** AI 聊天对话 列表 */
|
||||||
defineOptions({ name: 'ChatConversation' })
|
defineOptions({ name: 'AiChatManager' })
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,145 +0,0 @@
|
||||||
<template>
|
|
||||||
<el-drawer
|
|
||||||
v-model="showDrawer"
|
|
||||||
title="图片详细"
|
|
||||||
@close="handlerDrawerClose"
|
|
||||||
custom-class="drawer-class"
|
|
||||||
>
|
|
||||||
<!-- 图片 -->
|
|
||||||
<div class="item">
|
|
||||||
<!-- <div class="header">-->
|
|
||||||
<!-- <div>图片</div>-->
|
|
||||||
<!-- <div>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<!-- </div>-->
|
|
||||||
<div class="body">
|
|
||||||
<!-- TODO @fan: 要不,这里只展示图片???不用 ImageTaskCard -->
|
|
||||||
<ImageTaskCard :image-detail="imageDetail" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 时间 -->
|
|
||||||
<div class="item">
|
|
||||||
<div class="tip">时间</div>
|
|
||||||
<div class="body">
|
|
||||||
<div>提交时间:{{imageDetail.createTime}}</div>
|
|
||||||
<!-- TODO @fan:要不加个完成时间的字段 finishTime?updateTime 不算特别合理哈 -->
|
|
||||||
<div>生成时间:{{imageDetail.updateTime}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 模型 -->
|
|
||||||
<div class="item">
|
|
||||||
<div class="tip">模型</div>
|
|
||||||
<div class="body">
|
|
||||||
{{imageDetail.model}}({{imageDetail.height}}x{{imageDetail.width}})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 提示词 -->
|
|
||||||
<div class="item">
|
|
||||||
<div class="tip">提示词</div>
|
|
||||||
<div class="body">
|
|
||||||
{{imageDetail.prompt}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 地址 -->
|
|
||||||
<div class="item">
|
|
||||||
<div class="tip">图片地址</div>
|
|
||||||
<div class="body">
|
|
||||||
{{imageDetail.picUrl}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 风格 -->
|
|
||||||
<div class="item" v-if="imageDetail?.options?.style">
|
|
||||||
<div class="tip">风格</div>
|
|
||||||
<div class="body">
|
|
||||||
<!-- TODO @fan:貌似需要把 imageStyleList 搞到 api/image/index.ts 枚举起来? -->
|
|
||||||
<!-- TODO @fan:这里的展示,可能需要按照平台做区分 -->
|
|
||||||
{{imageDetail?.options?.style}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-drawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ImageApi, ImageDetailVO} from '@/api/ai/image';
|
|
||||||
import ImageTaskCard from './ImageTaskCard.vue';
|
|
||||||
|
|
||||||
const showDrawer = ref<boolean>(false) // 是否显示
|
|
||||||
const imageDetail = ref<ImageDetailVO>({} as ImageDetailVO) // 图片详细信息
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
require: true,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
id: {
|
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 抽屉 - close */
|
|
||||||
const handlerDrawerClose = async () => {
|
|
||||||
emits('handlerDrawerClose')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取 - 图片 detail */
|
|
||||||
const getImageDetail = async (id) => {
|
|
||||||
// 获取图片详细
|
|
||||||
imageDetail.value = await ImageApi.getImageDetail(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 任务 - detail */
|
|
||||||
const handlerTaskDetail = async () => {
|
|
||||||
showDrawer.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// watch show
|
|
||||||
const { show } = toRefs(props)
|
|
||||||
watch(show, async (newValue, oldValue) => {
|
|
||||||
showDrawer.value = newValue as boolean
|
|
||||||
})
|
|
||||||
// watch id
|
|
||||||
const { id } = toRefs(props)
|
|
||||||
watch(id, async (newVal, oldVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
await getImageDetail(newVal)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
//
|
|
||||||
const emits = defineEmits(['handlerDrawerClose'])
|
|
||||||
//
|
|
||||||
onMounted(async () => {
|
|
||||||
|
|
||||||
})
|
|
||||||
</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>
|
|
|
@ -1,210 +0,0 @@
|
||||||
<template>
|
|
||||||
<el-card class="dr-task" body-class="task-card" shadow="never">
|
|
||||||
<template #header>绘画任务</template>
|
|
||||||
<div class="task-image-list" ref="imageTaskRef">
|
|
||||||
<ImageTaskCard
|
|
||||||
v-for="image in imageList"
|
|
||||||
:key="image"
|
|
||||||
:image-detail="image"
|
|
||||||
|
|
||||||
@on-btn-click="handlerImageBtnClick"
|
|
||||||
@on-mj-btn-click="handlerImageMjBtnClick"/>
|
|
||||||
</div>
|
|
||||||
<div class="task-image-pagination">
|
|
||||||
<el-pagination background layout="prev, pager, next"
|
|
||||||
:default-page-size="pageSize"
|
|
||||||
:total="pageTotal"
|
|
||||||
@change="handlerPageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
<!-- 图片 detail 抽屉 -->
|
|
||||||
<ImageDetailDrawer
|
|
||||||
:show="isShowImageDetail"
|
|
||||||
:id="showImageDetailId"
|
|
||||||
@handler-drawer-close="handlerDrawerClose"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ImageApi, ImageDetailVO, ImageMjActionVO, ImageMjButtonsVO} from '@/api/ai/image';
|
|
||||||
import ImageDetailDrawer from './ImageDetailDrawer.vue'
|
|
||||||
import ImageTaskCard from './ImageTaskCard.vue'
|
|
||||||
import {ElLoading, LoadingOptionsResolved} from "element-plus";
|
|
||||||
|
|
||||||
const message = useMessage() // 消息弹窗
|
|
||||||
|
|
||||||
const imageList = ref<ImageDetailVO[]>([]) // image 列表
|
|
||||||
const imageListInterval = ref<any>() // image 列表定时器,刷新列表
|
|
||||||
const isShowImageDetail = ref<boolean>(false) // 是否显示 task 详情
|
|
||||||
const showImageDetailId = ref<number>(0) // 是否显示 task 详情
|
|
||||||
const imageTaskRef = ref<any>() // ref
|
|
||||||
const imageTaskLoadingInstance = ref<any>() // loading
|
|
||||||
const imageTaskLoading = ref<boolean>(false) // loading
|
|
||||||
const pageNo = ref<number>(1) // page no
|
|
||||||
const pageSize = ref<number>(10) // page size
|
|
||||||
const pageTotal = ref<number>(0) // page size
|
|
||||||
|
|
||||||
/** 抽屉 - close */
|
|
||||||
const handlerDrawerClose = async () => {
|
|
||||||
isShowImageDetail.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 任务 - detail */
|
|
||||||
const handlerDrawerOpen = async () => {
|
|
||||||
isShowImageDetail.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 - image 列表
|
|
||||||
*/
|
|
||||||
const getImageList = async (apply:boolean = false) => {
|
|
||||||
imageTaskLoading.value = true
|
|
||||||
try {
|
|
||||||
imageTaskLoadingInstance.value = ElLoading.service({
|
|
||||||
target: imageTaskRef.value,
|
|
||||||
text: '加载中...'
|
|
||||||
} as LoadingOptionsResolved)
|
|
||||||
const { list, total } = await ImageApi.getImageList({pageNo: pageNo.value, pageSize: pageSize.value})
|
|
||||||
if (apply) {
|
|
||||||
imageList.value = [...imageList.value, ...list]
|
|
||||||
} else {
|
|
||||||
imageList.value = list
|
|
||||||
}
|
|
||||||
pageTotal.value = total
|
|
||||||
} finally {
|
|
||||||
if (imageTaskLoadingInstance.value) {
|
|
||||||
imageTaskLoadingInstance.value.close();
|
|
||||||
imageTaskLoadingInstance.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图片 - btn click */
|
|
||||||
const handlerImageBtnClick = async (type, imageDetail: ImageDetailVO) => {
|
|
||||||
// 获取 image detail id
|
|
||||||
showImageDetailId.value = imageDetail.id
|
|
||||||
console.log('type', imageDetail.id)
|
|
||||||
// 处理不用 btn
|
|
||||||
if (type === 'more') {
|
|
||||||
await handlerDrawerOpen()
|
|
||||||
} else if (type === 'delete') {
|
|
||||||
await message.confirm(`是否删除照片?`)
|
|
||||||
await ImageApi.deleteImage(imageDetail.id)
|
|
||||||
await getImageList()
|
|
||||||
await message.success("删除成功!")
|
|
||||||
} else if (type === 'download') {
|
|
||||||
await downloadImage(imageDetail.picUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图片 - mj btn click */
|
|
||||||
const handlerImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageDetailVO) => {
|
|
||||||
// 1、构建 params 参数
|
|
||||||
const params = {
|
|
||||||
id: imageDetail.id,
|
|
||||||
customId: button.customId,
|
|
||||||
} as ImageMjActionVO
|
|
||||||
// 2、发送 action
|
|
||||||
await ImageApi.midjourneyAction(params)
|
|
||||||
// 3、刷新列表
|
|
||||||
await getImageList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下载 - image */
|
|
||||||
// TODO @fan:貌似可以考虑抽到 download 里面,作为一个方法
|
|
||||||
const downloadImage = async (imageUrl) => {
|
|
||||||
const image = new Image()
|
|
||||||
image.setAttribute('crossOrigin', 'anonymous')
|
|
||||||
image.src = imageUrl
|
|
||||||
image.onload = () => {
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = image.width
|
|
||||||
canvas.height = image.height
|
|
||||||
const ctx = canvas.getContext('2d') as CanvasDrawImage
|
|
||||||
ctx.drawImage(image, 0, 0, image.width, image.height)
|
|
||||||
const url = canvas.toDataURL('image/png')
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = 'image.png'
|
|
||||||
a.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// page change
|
|
||||||
const handlerPageChange = async (page) => {
|
|
||||||
pageNo.value = page
|
|
||||||
await getImageList(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 暴露组件方法 */
|
|
||||||
defineExpose({getImageList})
|
|
||||||
|
|
||||||
/** 组件挂在的时候 */
|
|
||||||
onMounted(async () => {
|
|
||||||
// 获取 image 列表
|
|
||||||
await getImageList()
|
|
||||||
// 自动刷新 image 列表
|
|
||||||
imageListInterval.value = setInterval(async () => {
|
|
||||||
await getImageList(false)
|
|
||||||
}, 1000 * 20)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 组件取消挂在的时候 */
|
|
||||||
onUnmounted(async () => {
|
|
||||||
if (imageListInterval.value) {
|
|
||||||
clearInterval(imageListInterval.value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.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;
|
|
||||||
padding-bottom: 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>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.dr-task {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,135 +0,0 @@
|
||||||
<template>
|
|
||||||
<el-card body-class="" class="image-card">
|
|
||||||
<div class="image-operation">
|
|
||||||
<div>
|
|
||||||
<el-button type="primary" text bg v-if="imageDetail?.status === 10">生成中</el-button>
|
|
||||||
<el-button text bg v-else-if="imageDetail?.status === 20">已完成</el-button>
|
|
||||||
<el-button type="danger" text bg v-else-if="imageDetail?.status === 30">异常</el-button>
|
|
||||||
</div>
|
|
||||||
<!-- TODO @fan:1)按钮要不调整成详情、下载、再次生成、删除?;2)如果是再次生成,就把当前的参数填写到左侧的框框里? -->
|
|
||||||
<div>
|
|
||||||
<el-button class="btn" text :icon="Download"
|
|
||||||
@click="handlerBtnClick('download', imageDetail)"/>
|
|
||||||
<el-button class="btn" text :icon="Delete" @click="handlerBtnClick('delete', imageDetail)"/>
|
|
||||||
<el-button class="btn" text :icon="More" @click="handlerBtnClick('more', imageDetail)"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="image-wrapper" ref="cardImageRef">
|
|
||||||
<!-- TODO @fan:要不加个点击,大图预览? -->
|
|
||||||
<img class="image" :src="imageDetail?.picUrl"/>
|
|
||||||
<div v-if="imageDetail?.status === 30">{{imageDetail?.errorMessage}}</div>
|
|
||||||
</div>
|
|
||||||
<!-- TODO @fan:style 使用 unocss 替代下 -->
|
|
||||||
<div class="image-mj-btns">
|
|
||||||
<el-button size="small" v-for="button in imageDetail?.buttons" :key="button"
|
|
||||||
style="min-width: 40px;margin-left: 0; margin-right: 10px; margin-top: 5px;"
|
|
||||||
@click="handlerMjBtnClick(button)"
|
|
||||||
>
|
|
||||||
{{ button.label }}{{ button.emoji }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {Delete, Download, More} from "@element-plus/icons-vue";
|
|
||||||
import {ImageDetailVO, ImageMjButtonsVO} from "@/api/ai/image";
|
|
||||||
import {PropType} from "vue";
|
|
||||||
import {ElLoading, ElMessageBox} from "element-plus";
|
|
||||||
|
|
||||||
const cardImageRef = ref<any>() // 卡片 image ref
|
|
||||||
const cardImageLoadingInstance = ref<any>() // 卡片 image ref
|
|
||||||
const message = useMessage()
|
|
||||||
const props = defineProps({
|
|
||||||
imageDetail: {
|
|
||||||
type: Object as PropType<ImageDetailVO>,
|
|
||||||
require: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 按钮 - 点击事件 */
|
|
||||||
const handlerBtnClick = async (type, imageDetail: ImageDetailVO) => {
|
|
||||||
emits('onBtnClick', type, imageDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlerLoading = async (status: number) => {
|
|
||||||
// TODO @fan:这个搞成 Loading 组件,然后通过数据驱动,这样搞可以哇?
|
|
||||||
if (status === 10) {
|
|
||||||
cardImageLoadingInstance.value = ElLoading.service({
|
|
||||||
target: cardImageRef.value,
|
|
||||||
text: '生成中...'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
if (cardImageLoadingInstance.value) {
|
|
||||||
cardImageLoadingInstance.value.close();
|
|
||||||
cardImageLoadingInstance.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** mj 按钮 click */
|
|
||||||
const handlerMjBtnClick = async (button: ImageMjButtonsVO) => {
|
|
||||||
// 确认窗体
|
|
||||||
await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`)
|
|
||||||
emits('onMjBtnClick', button, props.imageDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
// watch
|
|
||||||
const { imageDetail } = toRefs(props)
|
|
||||||
watch(imageDetail, async (newVal, oldVal) => {
|
|
||||||
await handlerLoading(newVal.status as string)
|
|
||||||
})
|
|
||||||
|
|
||||||
// emits
|
|
||||||
const emits = defineEmits(['onBtnClick', 'onMjBtnClick'])
|
|
||||||
|
|
||||||
//
|
|
||||||
onMounted(async () => {
|
|
||||||
await handlerLoading(props.imageDetail.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>
|
|
|
@ -1,398 +0,0 @@
|
||||||
<!-- dall3 -->
|
|
||||||
<template>
|
|
||||||
<div class="prompt">
|
|
||||||
<el-text tag="b">画面描述</el-text>
|
|
||||||
<el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
|
|
||||||
<!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
|
|
||||||
<el-input
|
|
||||||
v-model="prompt"
|
|
||||||
maxlength="1024"
|
|
||||||
rows="5"
|
|
||||||
style="width: 100%; margin-top: 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 hotWords"
|
|
||||||
:key="hotWord"
|
|
||||||
@click="handlerHotWordClick(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 ? 'modal-item selectModel' : 'modal-item'"
|
|
||||||
v-for="model in models"
|
|
||||||
:key="model.key"
|
|
||||||
|
|
||||||
>
|
|
||||||
<el-image
|
|
||||||
:src="model.image"
|
|
||||||
fit="contain"
|
|
||||||
@click="handlerModelClick(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="selectImageStyle === imageStyle ? 'image-style-item selectImageStyle' : 'image-style-item'"
|
|
||||||
v-for="imageStyle in imageStyleList"
|
|
||||||
:key="imageStyle.key"
|
|
||||||
>
|
|
||||||
<el-image
|
|
||||||
:src="imageStyle.image"
|
|
||||||
fit="contain"
|
|
||||||
@click="handlerStyleClick(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 imageSizeList"
|
|
||||||
:key="imageSize.key"
|
|
||||||
@click="handlerSizeClick(imageSize)">
|
|
||||||
<div :class="selectImageSize === imageSize ? '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"
|
|
||||||
@click="handlerGenerateImage">
|
|
||||||
{{drawIn ? '生成中' : '生成内容'}}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
|
|
||||||
|
|
||||||
// image 模型
|
|
||||||
interface ImageModelVO {
|
|
||||||
key: string
|
|
||||||
name: string
|
|
||||||
image: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// image 大小
|
|
||||||
interface ImageSizeVO {
|
|
||||||
key: string
|
|
||||||
name: string,
|
|
||||||
style: string,
|
|
||||||
width: string,
|
|
||||||
height: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义属性
|
|
||||||
const prompt = ref<string>('') // 提示词
|
|
||||||
const drawIn = ref<boolean>(false) // 生成中
|
|
||||||
const selectHotWord = ref<string>('') // 选中的热词
|
|
||||||
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) // 热词
|
|
||||||
const selectModel = ref<any>({}) // 模型
|
|
||||||
// message
|
|
||||||
const message = useMessage()
|
|
||||||
// TODO @fan:image 改成项目里自己的哈
|
|
||||||
// TODO @fan:这个 image,要不看看网上有没合适的图片,作为占位符,啊哈哈
|
|
||||||
const models = ref<ImageModelVO[]>([
|
|
||||||
{
|
|
||||||
key: 'dall-e-3',
|
|
||||||
name: 'DALL·E 3',
|
|
||||||
image: 'https://h5.cxyhub.com/images/model_2.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'dall-e-2',
|
|
||||||
name: 'DALL·E 2',
|
|
||||||
image: 'https://h5.cxyhub.com/images/model_1.png',
|
|
||||||
},
|
|
||||||
]) // 模型
|
|
||||||
selectModel.value = models.value[0]
|
|
||||||
|
|
||||||
const selectImageStyle = ref<any>({}) // style 样式
|
|
||||||
// TODO @fan:image 改成项目里自己的哈
|
|
||||||
const imageStyleList = ref<ImageModelVO[]>([
|
|
||||||
{
|
|
||||||
key: 'vivid',
|
|
||||||
name: '清晰',
|
|
||||||
image: 'https://h5.cxyhub.com/images/model_1.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'natural',
|
|
||||||
name: '自然',
|
|
||||||
image: 'https://h5.cxyhub.com/images/model_2.png',
|
|
||||||
},
|
|
||||||
]) // style
|
|
||||||
selectImageStyle.value = imageStyleList.value[0]
|
|
||||||
|
|
||||||
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
|
|
||||||
const imageSizeList = ref<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;',
|
|
||||||
}
|
|
||||||
]) // size
|
|
||||||
selectImageSize.value = imageSizeList.value[0]
|
|
||||||
|
|
||||||
// 定义 Props
|
|
||||||
const props = defineProps({})
|
|
||||||
// 定义 emits
|
|
||||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
|
|
||||||
|
|
||||||
// TODO @fan:如果是简单注释,建议用 /** */,主要是现在项目里是这种风格哈,保持一致好点~
|
|
||||||
// TODO @fan:handler 应该改成 handle 哈
|
|
||||||
/** 热词 - click */
|
|
||||||
const handlerHotWordClick = async (hotWord: string) => {
|
|
||||||
// 取消选中
|
|
||||||
if (selectHotWord.value == hotWord) {
|
|
||||||
selectHotWord.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 选中
|
|
||||||
selectHotWord.value = hotWord
|
|
||||||
// 替换提示词
|
|
||||||
prompt.value = hotWord
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模型 - click */
|
|
||||||
const handlerModelClick = async (model: ImageModelVO) => {
|
|
||||||
if (selectModel.value === model) {
|
|
||||||
selectModel.value = {} as ImageModelVO
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectModel.value = model
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 样式 - click */
|
|
||||||
const handlerStyleClick = async (imageStyle: ImageModelVO) => {
|
|
||||||
if (selectImageStyle.value === imageStyle) {
|
|
||||||
selectImageStyle.value = {} as ImageModelVO
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectImageStyle.value = imageStyle
|
|
||||||
}
|
|
||||||
|
|
||||||
/** size - click */
|
|
||||||
const handlerSizeClick = async (imageSize: ImageSizeVO) => {
|
|
||||||
if (selectImageSize.value === imageSize) {
|
|
||||||
selectImageSize.value = {} as ImageSizeVO
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectImageSize.value = imageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图片生产 */
|
|
||||||
const handlerGenerateImage = async () => {
|
|
||||||
// 二次确认
|
|
||||||
await message.confirm(`确认生成内容?`)
|
|
||||||
try {
|
|
||||||
// 加载中
|
|
||||||
drawIn.value = true
|
|
||||||
// 回调
|
|
||||||
emits('onDrawStart', selectModel.value.key)
|
|
||||||
const form = {
|
|
||||||
platform: 'OpenAI',
|
|
||||||
prompt: prompt.value, // 提示词
|
|
||||||
model: selectModel.value.key, // 模型
|
|
||||||
width: selectImageSize.value.width, // size 不能为空
|
|
||||||
height: selectImageSize.value.height, // size 不能为空
|
|
||||||
options: {
|
|
||||||
style: selectImageStyle.value.key, // 图像生成的风格
|
|
||||||
}
|
|
||||||
} as ImageDrawReqVO
|
|
||||||
// 发送请求
|
|
||||||
await ImageApi.drawImage(form)
|
|
||||||
} finally {
|
|
||||||
// 回调
|
|
||||||
emits('onDrawComplete', selectModel.value.key)
|
|
||||||
// 加载结束
|
|
||||||
drawIn.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模型
|
|
||||||
.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>
|
|
|
@ -1,105 +0,0 @@
|
||||||
<!-- image -->
|
|
||||||
<template>
|
|
||||||
<div class="ai-image">
|
|
||||||
<div class="left">
|
|
||||||
<div class="segmented">
|
|
||||||
<el-segmented v-model="selectModel" :options="modelOptions" />
|
|
||||||
</div>
|
|
||||||
<div class="modal-switch-container">
|
|
||||||
<!-- TODO @fan:1)建议 Dall3 改成 OpenAI 绘图。因为 dall3 其实本质是模型;2)涉及到中英文的地方,中文和英文之间,有个空格哈 -->
|
|
||||||
<Dall3 v-if="selectModel === 'DALL3绘画'"
|
|
||||||
@on-draw-start="handlerDrawStart"
|
|
||||||
@on-draw-complete="handlerDrawComplete" />
|
|
||||||
<Midjourney v-if="selectModel === 'MJ绘画'" />
|
|
||||||
<StableDiffusion v-if="selectModel === 'Stable Diffusion'" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="main">
|
|
||||||
<ImageTask ref="imageTaskRef" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// TODO @fan:在整个挪到 /views/ai/image/index 目录。因为我想在 /views/ai/image/manager 做管理的功能,进行下区分!
|
|
||||||
import Dall3 from './dall3/index.vue'
|
|
||||||
import Midjourney from './midjourney/index.vue'
|
|
||||||
import StableDiffusion from './stable-diffusion/index.vue'
|
|
||||||
import ImageTask from './ImageTask.vue'
|
|
||||||
|
|
||||||
// ref
|
|
||||||
const imageTaskRef = ref<any>() // image task ref
|
|
||||||
|
|
||||||
// 定义属性
|
|
||||||
const selectModel = ref('Stable Diffusion')
|
|
||||||
const modelOptions = ['DALL3绘画', 'MJ绘画', 'Stable Diffusion']
|
|
||||||
const drawIn = ref<boolean>(false) // 生成中
|
|
||||||
|
|
||||||
/** 绘画 - start */
|
|
||||||
const handlerDrawStart = async (type) => {
|
|
||||||
// todo
|
|
||||||
drawIn.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 绘画 - complete */
|
|
||||||
const handlerDrawComplete = async (type) => {
|
|
||||||
drawIn.value = false
|
|
||||||
// todo
|
|
||||||
await imageTaskRef.value.getImageList()
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
onMounted( async () => {
|
|
||||||
})
|
|
||||||
|
|
||||||
</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: 350px;
|
|
||||||
|
|
||||||
.segmented {
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,320 @@
|
||||||
|
<!-- 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" @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
|
||||||
|
} from '@/views/ai/utils/constants'
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
|
// 定义属性
|
||||||
|
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 emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||||
|
|
||||||
|
/** 选择热词 */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择 style 样式 */
|
||||||
|
const handleStyleClick = async (imageStyle: ImageModelVO) => {
|
||||||
|
style.value = imageStyle.key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择 size 大小 */
|
||||||
|
const handleSizeClick = async (imageSize: ImageSizeVO) => {
|
||||||
|
selectSize.value = imageSize.key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图片生产 */
|
||||||
|
const handleGenerateImage = async () => {
|
||||||
|
// 二次确认
|
||||||
|
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, // 提示词
|
||||||
|
model: selectModel.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">
|
||||||
|
// 提示词
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型
|
||||||
|
.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>
|
|
@ -0,0 +1,326 @@
|
||||||
|
<!-- 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 @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'
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
|
// 定义属性
|
||||||
|
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 emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||||
|
|
||||||
|
/** 选择热词 */
|
||||||
|
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 () => {
|
||||||
|
// 二次确认
|
||||||
|
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,
|
||||||
|
model: selectModel.value,
|
||||||
|
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>
|
|
@ -0,0 +1,216 @@
|
||||||
|
<!-- 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="model" placeholder="Select" size="large" class="!w-350px">
|
||||||
|
<el-option v-for="item in models" :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" 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" @click="handleGenerateImage">
|
||||||
|
{{ drawIn ? '生成中' : '生成内容' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
|
||||||
|
import {
|
||||||
|
AiPlatformEnum,
|
||||||
|
ChatGlmModels,
|
||||||
|
ImageHotWords,
|
||||||
|
ImageModelVO,
|
||||||
|
OtherPlatformEnum,
|
||||||
|
QianFanModels,
|
||||||
|
TongYiWanXiangModels
|
||||||
|
} from '@/views/ai/utils/constants'
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
|
// 定义属性
|
||||||
|
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 models = ref<ImageModelVO[]>(TongYiWanXiangModels) // 模型 TongYiWanXiangModels、QianFanModels
|
||||||
|
const model = ref<string>(models.value[0].key) // 模型
|
||||||
|
|
||||||
|
const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||||
|
|
||||||
|
/** 选择热词 */
|
||||||
|
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', AiPlatformEnum.STABLE_DIFFUSION)
|
||||||
|
// 发送请求
|
||||||
|
const form = {
|
||||||
|
platform: otherPlatform.value,
|
||||||
|
model: model.value, // 模型
|
||||||
|
prompt: prompt.value, // 提示词
|
||||||
|
width: width.value, // 图片宽度
|
||||||
|
height: height.value, // 图片高度
|
||||||
|
options: {}
|
||||||
|
} as unknown 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台切换 */
|
||||||
|
const handlerPlatformChange = async (platform: string) => {
|
||||||
|
// 切换平台,切换模型、风格
|
||||||
|
if (AiPlatformEnum.TONG_YI === platform) {
|
||||||
|
models.value = TongYiWanXiangModels
|
||||||
|
} else if (AiPlatformEnum.YI_YAN === platform) {
|
||||||
|
models.value = QianFanModels
|
||||||
|
} else if (AiPlatformEnum.ZHI_PU === platform) {
|
||||||
|
models.value = ChatGlmModels
|
||||||
|
} else {
|
||||||
|
models.value = []
|
||||||
|
}
|
||||||
|
// 切换平台,默认选择一个风格
|
||||||
|
if (models.value.length > 0) {
|
||||||
|
model.value = models.value[0].key
|
||||||
|
} else {
|
||||||
|
model.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 暴露组件方法 */
|
||||||
|
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>
|
|
@ -0,0 +1,272 @@
|
||||||
|
<!-- 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" @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'
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
|
// 定义属性
|
||||||
|
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 emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
|
||||||
|
|
||||||
|
/** 选择热词 */
|
||||||
|
const handleHotWordClick = async (hotWord: string) => {
|
||||||
|
// 情况一:取消选中
|
||||||
|
if (selectHotWord.value == hotWord) {
|
||||||
|
selectHotWord.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况二:选中
|
||||||
|
selectHotWord.value = hotWord // 选中
|
||||||
|
prompt.value = hotWord // 替换提示词
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图片生成 */
|
||||||
|
const handleGenerateImage = async () => {
|
||||||
|
// 二次确认
|
||||||
|
if (hasChinese(prompt.value)) {
|
||||||
|
message.alert('暂不支持中文!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await message.confirm(`确认生成内容?`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 加载中
|
||||||
|
drawIn.value = true
|
||||||
|
// 回调
|
||||||
|
emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION)
|
||||||
|
// 发送请求
|
||||||
|
const form = {
|
||||||
|
platform: AiPlatformEnum.STABLE_DIFFUSION,
|
||||||
|
model: 'stable-diffusion-v1-6',
|
||||||
|
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>
|
|
@ -0,0 +1,141 @@
|
||||||
|
<!-- 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">
|
||||||
|
<Dall3
|
||||||
|
v-if="selectPlatform === AiPlatformEnum.OPENAI"
|
||||||
|
ref="dall3Ref"
|
||||||
|
@on-draw-start="handleDrawStart"
|
||||||
|
@on-draw-complete="handleDrawComplete"
|
||||||
|
/>
|
||||||
|
<Midjourney v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY" ref="midjourneyRef" />
|
||||||
|
<StableDiffusion
|
||||||
|
v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
|
||||||
|
ref="stableDiffusionRef"
|
||||||
|
@on-draw-complete="handleDrawComplete"
|
||||||
|
/>
|
||||||
|
<Other
|
||||||
|
v-if="selectPlatform === 'other'"
|
||||||
|
ref="otherRef"
|
||||||
|
@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 Other from './components/other/index.vue'
|
||||||
|
|
||||||
|
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 otherRef = ref<any>() // stable diffusion ref
|
||||||
|
|
||||||
|
// 定义属性
|
||||||
|
const selectPlatform = ref(AiPlatformEnum.MIDJOURNEY)
|
||||||
|
const platformOptions = [
|
||||||
|
{
|
||||||
|
label: 'DALL3 绘画',
|
||||||
|
value: AiPlatformEnum.OPENAI
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'MJ 绘画',
|
||||||
|
value: AiPlatformEnum.MIDJOURNEY
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stable Diffusion',
|
||||||
|
value: AiPlatformEnum.STABLE_DIFFUSION
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '其它',
|
||||||
|
value: 'other'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 绘画 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 重新设置不行?
|
||||||
|
}
|
||||||
|
</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: 350px;
|
||||||
|
|
||||||
|
.segmented {
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
|
@ -0,0 +1,251 @@
|
||||||
|
<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="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-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-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>
|
|
@ -1,388 +0,0 @@
|
||||||
<!-- 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"
|
|
||||||
style="width: 100%; margin-top: 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 hotWords"
|
|
||||||
:key="hotWord"
|
|
||||||
@click="handlerHotWordClick(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 imageSizeList"
|
|
||||||
:key="imageSize.key"
|
|
||||||
@click="handlerSizeClick(imageSize)">
|
|
||||||
<div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
|
|
||||||
<div :style="imageSize.style"></div>
|
|
||||||
</div>
|
|
||||||
<div class="size-font">{{ imageSize.key }}</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"
|
|
||||||
clearable
|
|
||||||
placeholder="请选择版本"
|
|
||||||
style="width: 350px"
|
|
||||||
@change="handlerChangeVersion"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<div
|
|
||||||
:class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
|
|
||||||
v-for="model in models"
|
|
||||||
:key="model.key"
|
|
||||||
|
|
||||||
>
|
|
||||||
<el-image
|
|
||||||
:src="model.image"
|
|
||||||
fit="contain"
|
|
||||||
@click="handlerModelClick(model)"
|
|
||||||
/>
|
|
||||||
<div class="model-font">{{model.name}}</div>
|
|
||||||
</div>
|
|
||||||
</el-space>
|
|
||||||
</div>
|
|
||||||
<div class="btns">
|
|
||||||
<!-- <el-button size="large" round>重置内容</el-button>-->
|
|
||||||
<el-button type="primary" size="large" round @click="handlerGenerateImage">生成内容</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
|
|
||||||
// image 模型
|
|
||||||
import {ImageApi, ImageMidjourneyImagineReqVO} from "@/api/ai/image";
|
|
||||||
// message
|
|
||||||
const message = useMessage()
|
|
||||||
// 定义 emits
|
|
||||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
|
|
||||||
|
|
||||||
interface ImageModelVO {
|
|
||||||
key: string
|
|
||||||
name: string
|
|
||||||
image: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// image 大小
|
|
||||||
interface ImageSizeVO {
|
|
||||||
key: string
|
|
||||||
style: string,
|
|
||||||
width: string,
|
|
||||||
height: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义属性
|
|
||||||
const prompt = ref<string>('') // 提示词
|
|
||||||
const selectHotWord = ref<string>('') // 选中的热词
|
|
||||||
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) // 热词
|
|
||||||
const selectModel = ref<any>() // 选中的热词
|
|
||||||
const models = ref<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',
|
|
||||||
},
|
|
||||||
]) // 模型
|
|
||||||
selectModel.value = models.value[0] // 默认选中
|
|
||||||
|
|
||||||
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
|
|
||||||
const imageSizeList = ref<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;',
|
|
||||||
},
|
|
||||||
]) // size
|
|
||||||
selectImageSize.value = imageSizeList.value[0]
|
|
||||||
|
|
||||||
|
|
||||||
// version
|
|
||||||
let versionList = ref<any>([]) // version 列表
|
|
||||||
const midjourneyVersionList = ref<any>([
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const nijiVersionList = ref<any>([
|
|
||||||
{
|
|
||||||
value: '5',
|
|
||||||
label: 'v5',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const selectVersion = ref<any>('6.0') // 选中的 version
|
|
||||||
versionList.value = midjourneyVersionList.value // 默认选择 midjourney
|
|
||||||
|
|
||||||
// 定义 Props
|
|
||||||
const props = defineProps({})
|
|
||||||
|
|
||||||
/** 热词 - click */
|
|
||||||
const handlerHotWordClick = async (hotWord: string) => {
|
|
||||||
// 取消
|
|
||||||
if (selectHotWord.value == hotWord) {
|
|
||||||
selectHotWord.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 选中
|
|
||||||
selectHotWord.value = hotWord
|
|
||||||
// 设置提示次
|
|
||||||
prompt.value = hotWord
|
|
||||||
}
|
|
||||||
|
|
||||||
/** size - click */
|
|
||||||
const handlerSizeClick = async (imageSize: ImageSizeVO) => {
|
|
||||||
if (selectImageSize.value === imageSize) {
|
|
||||||
selectImageSize.value = {} as ImageSizeVO
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectImageSize.value = imageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模型 - click */
|
|
||||||
const handlerModelClick = async (model: ImageModelVO) => {
|
|
||||||
selectModel.value = model
|
|
||||||
if (model.key === 'niji') {
|
|
||||||
versionList.value = nijiVersionList.value // 默认选择 niji
|
|
||||||
} else {
|
|
||||||
versionList.value = midjourneyVersionList.value // 默认选择 midjourney
|
|
||||||
}
|
|
||||||
selectVersion.value = versionList.value[0].value
|
|
||||||
}
|
|
||||||
|
|
||||||
/** version - click */
|
|
||||||
const handlerChangeVersion = async (version) => {
|
|
||||||
console.log('version', version)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图片生产 */
|
|
||||||
const handlerGenerateImage = async () => {
|
|
||||||
// 二次确认
|
|
||||||
await message.confirm(`确认生成内容?`)
|
|
||||||
// todo @范 图片生产逻辑
|
|
||||||
try {
|
|
||||||
// 回调
|
|
||||||
emits('onDrawStart', selectModel.value.key)
|
|
||||||
// 发送请求
|
|
||||||
const req = {
|
|
||||||
prompt: prompt.value,
|
|
||||||
model: selectModel.value.key,
|
|
||||||
width: selectImageSize.value.width,
|
|
||||||
height: selectImageSize.value.height,
|
|
||||||
version: selectVersion.value,
|
|
||||||
base64Array: [],
|
|
||||||
} as ImageMidjourneyImagineReqVO
|
|
||||||
await ImageApi.midjourneyImagine(req)
|
|
||||||
} finally {
|
|
||||||
// 回调
|
|
||||||
emits('onDrawComplete', selectModel.value.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
<template>
|
||||||
|
<div class="square-container">
|
||||||
|
<!-- TODO @fan:style 建议换成 unocss -->
|
||||||
|
<!-- TODO @fan:Search 可以换成 Icon 组件么? -->
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.prompt"
|
||||||
|
style="width: 100%; margin-bottom: 20px"
|
||||||
|
size="large"
|
||||||
|
placeholder="请输入要搜索的内容"
|
||||||
|
:suffix-icon="Search"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
<div class="gallery">
|
||||||
|
<!-- TODO @fan:这个图片的风格,要不和 ImageCard.vue 界面一致?(只有卡片,没有操作);因为看着更有相框的感觉~~~ -->
|
||||||
|
<div v-for="item in list" :key="item.id" class="gallery-item">
|
||||||
|
<img :src="item.picUrl" class="img" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- TODO @fan:缺少翻页 -->
|
||||||
|
<!-- 分页 -->
|
||||||
|
<Pagination
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNo"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ImageApi, ImageVO } from '@/api/ai/image'
|
||||||
|
import { Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// TODO @fan:加个 loading 加载中的状态
|
||||||
|
const loading = ref(true) // 列表的加载中
|
||||||
|
const list = ref<ImageVO[]>([]) // 列表的数据
|
||||||
|
const total = ref(0) // 列表的总页数
|
||||||
|
const queryParams = reactive({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
publicStatus: true,
|
||||||
|
prompt: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await ImageApi.getImagePageMy(queryParams)
|
||||||
|
list.value = data.list
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
const handleQuery = () => {
|
||||||
|
queryParams.pageNo = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(async () => {
|
||||||
|
await getList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.square-container {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
//max-width: 1000px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover img {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,297 +0,0 @@
|
||||||
<!-- dall3 -->
|
|
||||||
<template>
|
|
||||||
<div class="prompt">
|
|
||||||
<el-text tag="b">画面描述</el-text>
|
|
||||||
<el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
|
|
||||||
<!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
|
|
||||||
<el-input
|
|
||||||
v-model="prompt"
|
|
||||||
maxlength="1024"
|
|
||||||
rows="5"
|
|
||||||
style="width: 100%; margin-top: 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 hotWords"
|
|
||||||
:key="hotWord"
|
|
||||||
@click="handlerHotWordClick(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="selectSampler"
|
|
||||||
placeholder="Select"
|
|
||||||
size="large"
|
|
||||||
style="width: 350px;"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in sampler"
|
|
||||||
: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="selectImageSize"
|
|
||||||
placeholder="Select"
|
|
||||||
size="large"
|
|
||||||
style="width: 350px;"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="item in imageSizeList"
|
|
||||||
:key="item.key"
|
|
||||||
:label="item.key"
|
|
||||||
: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="steps" type="number" size="large" style="width: 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" style="width: 350px" placeholder="Please input" />
|
|
||||||
</el-space>
|
|
||||||
</div>
|
|
||||||
<div class="btns">
|
|
||||||
<el-button type="primary"
|
|
||||||
size="large"
|
|
||||||
round
|
|
||||||
:loading="drawIn"
|
|
||||||
@click="handlerGenerateImage">
|
|
||||||
{{drawIn ? '生成中' : '生成内容'}}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
|
|
||||||
|
|
||||||
// image 模型
|
|
||||||
interface ImageModelVO {
|
|
||||||
key: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// image 大小
|
|
||||||
interface ImageSizeVO {
|
|
||||||
key: string
|
|
||||||
width: string,
|
|
||||||
height: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义属性
|
|
||||||
const prompt = ref<string>('') // 提示词
|
|
||||||
const drawIn = ref<boolean>(false) // 生成中
|
|
||||||
const selectHotWord = ref<string>('') // 选中的热词
|
|
||||||
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) // 热词
|
|
||||||
const selectSampler = ref<any>({}) // 模型
|
|
||||||
// message
|
|
||||||
const message = useMessage()
|
|
||||||
// 模型
|
|
||||||
const sampler = ref<ImageModelVO[]>([
|
|
||||||
{
|
|
||||||
key: 'Euler a',
|
|
||||||
name: 'Euler a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DPM++ 2S a Karras',
|
|
||||||
name: 'DPM++ 2S a Karras',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DPM++ 2M Karras',
|
|
||||||
name: 'DPM++ 2M Karras',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DPM++ SDE Karras',
|
|
||||||
name: 'DPM++ SDE Karras',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'DPM++ 2M SDE Karras',
|
|
||||||
name: 'DPM++ 2M SDE Karras',
|
|
||||||
},
|
|
||||||
]) // 模型
|
|
||||||
selectSampler.value = sampler.value[0]
|
|
||||||
|
|
||||||
|
|
||||||
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
|
|
||||||
const imageSizeList = ref<ImageSizeVO[]>([
|
|
||||||
{
|
|
||||||
key: '512x512',
|
|
||||||
width: '512',
|
|
||||||
height: '512',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '1024x1024',
|
|
||||||
width: '1024',
|
|
||||||
height: '1024',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '1024x1792',
|
|
||||||
width: '1024',
|
|
||||||
height: '1792',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '1792x1024',
|
|
||||||
width: '1792',
|
|
||||||
height: '1024',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '2048x2048',
|
|
||||||
width: '2048',
|
|
||||||
height: '2048',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '720x1280',
|
|
||||||
width: '720',
|
|
||||||
height: '1280',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '1080x1920',
|
|
||||||
width: '1080',
|
|
||||||
height: '1920',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '1440x2560',
|
|
||||||
width: '1440',
|
|
||||||
height: '2560',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '2160x3840',
|
|
||||||
width: '2160',
|
|
||||||
height: '3840',
|
|
||||||
},
|
|
||||||
]) // size
|
|
||||||
selectImageSize.value = imageSizeList.value[0]
|
|
||||||
|
|
||||||
const steps = ref<number>(20) // 迭代步数
|
|
||||||
const seed = ref<number>(-1) // 控制生成图像的随机性
|
|
||||||
|
|
||||||
// 定义 Props
|
|
||||||
const props = defineProps({})
|
|
||||||
// 定义 emits
|
|
||||||
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
|
|
||||||
|
|
||||||
// TODO @fan:如果是简单注释,建议用 /** */,主要是现在项目里是这种风格哈,保持一致好点~
|
|
||||||
// TODO @fan:handler 应该改成 handle 哈
|
|
||||||
/** 热词 - click */
|
|
||||||
const handlerHotWordClick = async (hotWord: string) => {
|
|
||||||
// 取消选中
|
|
||||||
if (selectHotWord.value == hotWord) {
|
|
||||||
selectHotWord.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 选中
|
|
||||||
selectHotWord.value = hotWord
|
|
||||||
// 替换提示词
|
|
||||||
prompt.value = hotWord
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 图片生产 */
|
|
||||||
const handlerGenerateImage = async () => {
|
|
||||||
// 二次确认
|
|
||||||
await message.confirm(`确认生成内容?`)
|
|
||||||
try {
|
|
||||||
// 加载中
|
|
||||||
drawIn.value = true
|
|
||||||
// 回调
|
|
||||||
emits('onDrawStart', 'StableDiffusion')
|
|
||||||
const form = {
|
|
||||||
platform: 'StableDiffusion',
|
|
||||||
model: 'stable-diffusion-v1-6',
|
|
||||||
prompt: prompt.value, // 提示词
|
|
||||||
width: selectImageSize.value.width, // 图片宽度
|
|
||||||
height: selectImageSize.value.height, // 图片高度
|
|
||||||
options: {
|
|
||||||
sampler: selectSampler.value.key, // 采样算法
|
|
||||||
seed: seed.value, // 随机种子
|
|
||||||
steps: steps.value, // 图片生成步数
|
|
||||||
},
|
|
||||||
} as ImageDrawReqVO
|
|
||||||
// 发送请求
|
|
||||||
await ImageApi.drawImage(form)
|
|
||||||
} finally {
|
|
||||||
// 回调
|
|
||||||
emits('onDrawComplete', 'StableDiffusion')
|
|
||||||
// 加载结束
|
|
||||||
drawIn.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</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>
|
|
|
@ -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>
|
|
@ -0,0 +1,163 @@
|
||||||
|
<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 type="primary" v-show="isEnd" @click="downloadImage" 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">
|
||||||
|
<!--展示 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" class="w-full" :style="{ height: `${contentAreaHeight}px` }" />
|
||||||
|
<div ref="toolBarRef" class="absolute bottom-[10px] right-5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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>
|
|
@ -0,0 +1,92 @@
|
||||||
|
<template>
|
||||||
|
<div class="absolute top-0 left-0 right-0 bottom-0 flex">
|
||||||
|
<!--表单区域-->
|
||||||
|
<Left
|
||||||
|
ref="leftRef"
|
||||||
|
@submit="submit"
|
||||||
|
@direct-generate="directGenerate"
|
||||||
|
:is-generating="isGenerating"
|
||||||
|
/>
|
||||||
|
<!--右边生成思维导图区域-->
|
||||||
|
<Right
|
||||||
|
ref="rightRef"
|
||||||
|
:generatedContent="generatedContent"
|
||||||
|
:isEnd="isEnd"
|
||||||
|
:isGenerating="isGenerating"
|
||||||
|
:isStart="isStart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
ctrl: ctrl.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(() => {
|
||||||
|
generatedContent.value = MindMapContentExample
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -0,0 +1,189 @@
|
||||||
|
<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="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-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-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>
|
|
@ -13,7 +13,7 @@
|
||||||
<el-form-item label="角色头像" prop="avatar">
|
<el-form-item label="角色头像" prop="avatar">
|
||||||
<UploadImg v-model="formData.avatar" height="60px" width="60px" />
|
<UploadImg v-model="formData.avatar" height="60px" width="60px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="绑定模型" prop="modelId" v-if="!isUser(formType)">
|
<el-form-item label="绑定模型" prop="modelId" v-if="!isUser">
|
||||||
<el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
|
<el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="chatModel in chatModelList"
|
v-for="chatModel in chatModelList"
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色类别" prop="category" v-if="!isUser(formType)">
|
<el-form-item label="角色类别" prop="category" v-if="!isUser">
|
||||||
<el-input v-model="formData.category" placeholder="请输入角色类别" />
|
<el-input v-model="formData.category" placeholder="请输入角色类别" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色描述" prop="description">
|
<el-form-item label="角色描述" prop="description">
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
<el-form-item label="角色设定" prop="systemMessage">
|
<el-form-item label="角色设定" prop="systemMessage">
|
||||||
<el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
|
<el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="是否公开" prop="publicStatus" v-if="!isUser(formType)">
|
<el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
|
||||||
<el-radio-group v-model="formData.publicStatus">
|
<el-radio-group v-model="formData.publicStatus">
|
||||||
<el-radio
|
<el-radio
|
||||||
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
|
||||||
|
@ -43,10 +43,10 @@
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色排序" prop="sort" v-if="!isUser(formType)">
|
<el-form-item label="角色排序" prop="sort" v-if="!isUser">
|
||||||
<el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
|
<el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="开启状态" prop="status" v-if="!isUser(formType)">
|
<el-form-item label="开启状态" prop="status" v-if="!isUser">
|
||||||
<el-radio-group v-model="formData.status">
|
<el-radio-group v-model="formData.status">
|
||||||
<el-radio
|
<el-radio
|
||||||
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
|
||||||
|
@ -69,6 +69,7 @@ import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||||
import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
|
import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
|
||||||
import { CommonStatusEnum } from '@/utils/constants'
|
import { CommonStatusEnum } from '@/utils/constants'
|
||||||
import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
|
import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
|
||||||
|
import {FormRules} from "element-plus";
|
||||||
|
|
||||||
/** AI 聊天角色 表单 */
|
/** AI 聊天角色 表单 */
|
||||||
defineOptions({ name: 'ChatRoleForm' })
|
defineOptions({ name: 'ChatRoleForm' })
|
||||||
|
@ -92,30 +93,23 @@ const formData = ref({
|
||||||
publicStatus: true,
|
publicStatus: true,
|
||||||
status: CommonStatusEnum.ENABLE
|
status: CommonStatusEnum.ENABLE
|
||||||
})
|
})
|
||||||
const formRules = ref() // reactive(formRulesObj)
|
|
||||||
const formRef = ref() // 表单 Ref
|
const formRef = ref() // 表单 Ref
|
||||||
const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
|
const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
|
||||||
|
|
||||||
/** 是否【我】自己创建,私有角色 */
|
/** 是否【我】自己创建,私有角色 */
|
||||||
// TODO @fan:建议改成计算函数 computed
|
const isUser = computed(() => {
|
||||||
const isUser = (type: string) => {
|
return formType.value === 'my-create' || formType.value === 'my-update'
|
||||||
return type === 'my-create' || type === 'my-update'
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// TODO @fan:直接使用 formRules;只要隐藏掉的字段,它是不会校验的哈;
|
const formRules = reactive<FormRules>({
|
||||||
const getFormRules = async (type: string) => {
|
name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
|
||||||
let formRulesObj = {
|
avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
|
||||||
name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
|
category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
|
||||||
avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
|
sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
|
||||||
category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
|
description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
|
||||||
sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
|
systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
|
||||||
description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
|
publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
|
||||||
systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
|
})
|
||||||
publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
formRules.value = reactive(formRulesObj)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开弹窗 */
|
/** 打开弹窗 */
|
||||||
// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
|
// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
|
||||||
|
@ -123,7 +117,6 @@ const open = async (type: string, id?: number, title?: string) => {
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
dialogTitle.value = title || t('action.' + type)
|
dialogTitle.value = title || t('action.' + type)
|
||||||
formType.value = type
|
formType.value = type
|
||||||
getFormRules(type)
|
|
||||||
resetForm()
|
resetForm()
|
||||||
// 修改时,设置数据
|
// 修改时,设置数据
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,41 @@
|
||||||
|
<template>
|
||||||
|
<ContentWrap class="w-300px h-full mb-[0!important]">
|
||||||
|
<el-radio-group v-model="generateMode" class="mb-15px">
|
||||||
|
<el-radio-button label="desc">
|
||||||
|
描述模式
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button label="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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,292 @@
|
||||||
|
<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="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-220px"
|
||||||
|
/>
|
||||||
|
</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>
|
|
@ -0,0 +1,481 @@
|
||||||
|
/**
|
||||||
|
* 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', // 讯飞
|
||||||
|
OPENAI: 'OpenAI',
|
||||||
|
Ollama: 'Ollama',
|
||||||
|
STABLE_DIFFUSION: 'StableDiffusion', // Stability AI
|
||||||
|
MIDJOURNEY: 'Midjourney', // Midjourney
|
||||||
|
SUNO: 'Suno' // Suno AI
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OtherPlatformEnum: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: AiPlatformEnum.TONG_YI,
|
||||||
|
name: '通义万相'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: AiPlatformEnum.YI_YAN,
|
||||||
|
name: '百度千帆'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: AiPlatformEnum.ZHI_PU,
|
||||||
|
name: '智谱 AI'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 TongYiWanXiangModels: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'wanx-v1',
|
||||||
|
name: 'wanx-v1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wanx-sketch-to-image-v1',
|
||||||
|
name: 'wanx-sketch-to-image-v1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const QianFanModels: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'sd_xl',
|
||||||
|
name: 'sd_xl'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ChatGlmModels: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'cogview-3',
|
||||||
|
name: 'cogview-3'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const StableDiffusionClipGuidancePresets: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'NONE',
|
||||||
|
name: 'NONE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FAST_BLUE',
|
||||||
|
name: 'FAST_BLUE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FAST_GREEN',
|
||||||
|
name: 'FAST_GREEN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SIMPLE',
|
||||||
|
name: 'SIMPLE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SLOW',
|
||||||
|
name: 'SLOW'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SLOWER',
|
||||||
|
name: 'SLOWER'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SLOWEST',
|
||||||
|
name: 'SLOWEST'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Dall3Models: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'dall-e-3',
|
||||||
|
name: 'DALL·E 3',
|
||||||
|
image: `/src/assets/ai/dall2.jpg`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dall-e-2',
|
||||||
|
name: 'DALL·E 2',
|
||||||
|
image: `/src/assets/ai/dall3.jpg`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Dall3StyleList: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'vivid',
|
||||||
|
name: '清晰',
|
||||||
|
image: `/src/assets/ai/qingxi.jpg`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'natural',
|
||||||
|
name: '自然',
|
||||||
|
image: `/src/assets/ai/ziran.jpg`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface ImageSizeVO {
|
||||||
|
key: string
|
||||||
|
name?: string
|
||||||
|
style: string
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dall3SizeList: ImageSizeVO[] = [
|
||||||
|
{
|
||||||
|
key: '1024x1024',
|
||||||
|
name: '1:1',
|
||||||
|
width: '1024',
|
||||||
|
height: '1024',
|
||||||
|
style: 'width: 30px; height: 30px;background-color: #dcdcdc;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '1024x1792',
|
||||||
|
name: '3:5',
|
||||||
|
width: '1024',
|
||||||
|
height: '1792',
|
||||||
|
style: 'width: 30px; height: 50px;background-color: #dcdcdc;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '1792x1024',
|
||||||
|
name: '5:3',
|
||||||
|
width: '1792',
|
||||||
|
height: '1024',
|
||||||
|
style: 'width: 50px; height: 30px;background-color: #dcdcdc;'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MidjourneyModels: ImageModelVO[] = [
|
||||||
|
{
|
||||||
|
key: 'midjourney',
|
||||||
|
name: 'MJ',
|
||||||
|
image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'niji',
|
||||||
|
name: 'NIJI',
|
||||||
|
image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MidjourneySizeList: ImageSizeVO[] = [
|
||||||
|
{
|
||||||
|
key: '1:1',
|
||||||
|
width: '1',
|
||||||
|
height: '1',
|
||||||
|
style: 'width: 30px; height: 30px;background-color: #dcdcdc;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3:4',
|
||||||
|
width: '3',
|
||||||
|
height: '4',
|
||||||
|
style: 'width: 30px; height: 40px;background-color: #dcdcdc;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '4:3',
|
||||||
|
width: '4',
|
||||||
|
height: '3',
|
||||||
|
style: 'width: 40px; height: 30px;background-color: #dcdcdc;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '9:16',
|
||||||
|
width: '9',
|
||||||
|
height: '16',
|
||||||
|
style: 'width: 30px; height: 50px;background-color: #dcdcdc;'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '16:9',
|
||||||
|
width: '16',
|
||||||
|
height: '9',
|
||||||
|
style: 'width: 50px; height: 30px;background-color: #dcdcdc;'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MidjourneyVersions = [
|
||||||
|
{
|
||||||
|
value: '6.0',
|
||||||
|
label: 'v6.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '5.2',
|
||||||
|
label: 'v5.2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '5.1',
|
||||||
|
label: 'v5.1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '5.0',
|
||||||
|
label: 'v5.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: '4.0',
|
||||||
|
label: 'v4.0'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NijiVersionList = [
|
||||||
|
{
|
||||||
|
value: '5',
|
||||||
|
label: 'v5'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// ========== 【写作 UI】相关的枚举 ==========
|
||||||
|
|
||||||
|
/** 写作点击示例时的数据 **/
|
||||||
|
export const WriteExample = {
|
||||||
|
write: {
|
||||||
|
prompt: 'vue',
|
||||||
|
data: 'Vue.js 是一种用于构建用户界面的渐进式 JavaScript 框架。它的核心库只关注视图层,易于上手,同时也便于与其他库或已有项目整合。\n\nVue.js 的特点包括:\n- 响应式的数据绑定:Vue.js 会自动将数据与 DOM 同步,使得状态管理变得更加简单。\n- 组件化:Vue.js 允许开发者通过小型、独立和通常可复用的组件构建大型应用。\n- 虚拟 DOM:Vue.js 使用虚拟 DOM 实现快速渲染,提高了性能。\n\n在 Vue.js 中,一个典型的应用结构可能包括:\n1. 根实例:每个 Vue 应用都需要一个根实例作为入口点。\n2. 组件系统:可以创建自定义的可复用组件。\n3. 指令:特殊的带有前缀 v- 的属性,为 DOM 元素提供特殊的行为。\n4. 插值:用于文本内容,将数据动态地插入到 HTML。\n5. 计算属性和侦听器:用于处理数据的复杂逻辑和响应数据变化。\n6. 条件渲染:根据条件决定元素的渲染。\n7. 列表渲染:用于显示列表数据。\n8. 事件处理:响应用户交互。\n9. 表单输入绑定:处理表单输入和验证。\n10. 组件生命周期钩子:在组件的不同阶段执行特定的函数。\n\nVue.js 还提供了官方的路由器 Vue Router 和状态管理库 Vuex,以支持构建复杂的单页应用(SPA)。\n\n在开发过程中,开发者通常会使用 Vue CLI,这是一个强大的命令行工具,用于快速生成 Vue 项目脚手架,集成了诸如 Babel、Webpack 等现代前端工具,以及热重载、代码检测等开发体验优化功能。\n\nVue.js 的生态系统还包括大量的第三方库和插件,如 Vuetify(UI 组件库)、Vue Test Utils(测试工具)等,这些都极大地丰富了 Vue.js 的开发生态。\n\n总的来说,Vue.js 是一个灵活、高效的前端框架,适合从小型项目到大型企业级应用的开发。它的易用性、灵活性和强大的社区支持使其成为许多开发者的首选框架之一。'
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
originalContent: '领导,我想请假',
|
||||||
|
prompt: '不批',
|
||||||
|
data: '您的请假申请已收悉,经核实和考虑,暂时无法批准您的请假申请。\n\n如有特殊情况或紧急事务,请及时与我联系。\n\n祝工作顺利。\n\n谢谢。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 【思维导图 UI】相关的枚举 ==========
|
||||||
|
|
||||||
|
/** 思维导图已有内容生成示例 **/
|
||||||
|
export const MindMapContentExample = `# Java 技术栈
|
||||||
|
|
||||||
|
## 核心技术
|
||||||
|
### Java SE
|
||||||
|
### Java EE
|
||||||
|
|
||||||
|
## 框架
|
||||||
|
### Spring
|
||||||
|
#### Spring Boot
|
||||||
|
#### Spring MVC
|
||||||
|
#### Spring Data
|
||||||
|
### Hibernate
|
||||||
|
### MyBatis
|
||||||
|
|
||||||
|
## 构建工具
|
||||||
|
### Maven
|
||||||
|
### Gradle
|
||||||
|
|
||||||
|
## 版本控制
|
||||||
|
### Git
|
||||||
|
### SVN
|
||||||
|
|
||||||
|
## 测试工具
|
||||||
|
### JUnit
|
||||||
|
### Mockito
|
||||||
|
### Selenium
|
||||||
|
|
||||||
|
## 应用服务器
|
||||||
|
### Tomcat
|
||||||
|
### Jetty
|
||||||
|
### WildFly
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
### MySQL
|
||||||
|
### PostgreSQL
|
||||||
|
### Oracle
|
||||||
|
### MongoDB
|
||||||
|
|
||||||
|
## 消息队列
|
||||||
|
### Kafka
|
||||||
|
### RabbitMQ
|
||||||
|
### ActiveMQ
|
||||||
|
|
||||||
|
## 微服务
|
||||||
|
### Spring Cloud
|
||||||
|
### Dubbo
|
||||||
|
|
||||||
|
## 容器化
|
||||||
|
### Docker
|
||||||
|
### Kubernetes
|
||||||
|
|
||||||
|
## 云服务
|
||||||
|
### AWS
|
||||||
|
### Azure
|
||||||
|
### Google Cloud
|
||||||
|
|
||||||
|
## 开发工具
|
||||||
|
### IntelliJ IDEA
|
||||||
|
### Eclipse
|
||||||
|
### Visual Studio Code`
|
|
@ -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)
|
||||||
|
}
|
|
@ -0,0 +1,213 @@
|
||||||
|
<template>
|
||||||
|
<!-- 定义 tab 组件:撰写/回复等 -->
|
||||||
|
<DefineTab v-slot="{ active, text, itemClick }">
|
||||||
|
<span
|
||||||
|
class="inline-block w-1/2 rounded-full cursor-pointer text-center leading-[30px] relative z-1 text-[5C6370] hover:text-black"
|
||||||
|
:class="active ? 'text-black shadow-md' : 'hover:bg-[#DDDFE3]'"
|
||||||
|
@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
|
||||||
|
@click="hintClick"
|
||||||
|
v-if="hint"
|
||||||
|
class="flex items-center text-[12px] text-[#846af7] cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
<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="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"
|
||||||
|
:class="
|
||||||
|
selectedTab === AiWriteTypeEnum.REPLY && 'after:transform after:translate-x-[100%]'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ReuseTab
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.value"
|
||||||
|
:text="tab.text"
|
||||||
|
:active="tab.value === selectedTab"
|
||||||
|
:itemClick="() => switchTab(tab.value)"
|
||||||
|
/>
|
||||||
|
</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 label="写作内容" hint="示例" :hint-click="() => example('write')" />
|
||||||
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:maxlength="500"
|
||||||
|
v-model="formData.prompt"
|
||||||
|
placeholder="请输入写作内容"
|
||||||
|
showWordLimit
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<ReuseLabel label="原文" hint="示例" :hint-click="() => example('reply')" />
|
||||||
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:maxlength="500"
|
||||||
|
v-model="formData.originalContent"
|
||||||
|
placeholder="请输入原文"
|
||||||
|
showWordLimit
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReuseLabel label="回复内容" />
|
||||||
|
<el-input
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:maxlength="500"
|
||||||
|
v-model="formData.prompt"
|
||||||
|
placeholder="请输入回复内容"
|
||||||
|
showWordLimit
|
||||||
|
/>
|
||||||
|
</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" @click="submit" color="#846af7">生成</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { createReusableTemplate } from '@vueuse/core'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import Tag from './Tag.vue'
|
||||||
|
import { WriteVO } from 'src/api/ai/write'
|
||||||
|
import { omit } from 'lodash-es'
|
||||||
|
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
|
||||||
|
import { AiWriteTypeEnum, WriteExample } from '@/views/ai/utils/constants'
|
||||||
|
|
||||||
|
type TabType = WriteVO['type']
|
||||||
|
|
||||||
|
const message = useMessage() // 消息弹窗
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
isWriting: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'submit', params: Partial<WriteVO>)
|
||||||
|
(e: 'example', param: 'write' | 'reply')
|
||||||
|
(e: 'reset')
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 点击示例的时候,将定义好的文章作为示例展示出来 **/
|
||||||
|
const example = (type: 'write' | 'reply') => {
|
||||||
|
formData.value = {
|
||||||
|
...initData,
|
||||||
|
...omit(WriteExample[type], ['data'])
|
||||||
|
}
|
||||||
|
emits('example', type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置,将表单值作为初选值 **/
|
||||||
|
const reset = () => {
|
||||||
|
formData.value = { ...initData }
|
||||||
|
emits('reset')
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTab = ref<TabType>(AiWriteTypeEnum.WRITING)
|
||||||
|
const tabs: {
|
||||||
|
text: string
|
||||||
|
value: TabType
|
||||||
|
}[] = [
|
||||||
|
{ text: '撰写', value: AiWriteTypeEnum.WRITING },
|
||||||
|
{ text: '回复', value: AiWriteTypeEnum.REPLY }
|
||||||
|
]
|
||||||
|
const [DefineTab, ReuseTab] = createReusableTemplate<{
|
||||||
|
active?: boolean
|
||||||
|
text: string
|
||||||
|
itemClick: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可以在 template 里边定义可复用的组件,DefineLabel,ReuseLabel 是采用的解构赋值,都是 Vue 组件
|
||||||
|
*
|
||||||
|
* 直接通过组件的形式使用,<DefineLabel v-slot="{ label, hint, hintClick }"> 中间是需要复用的组件代码 <DefineLabel />,通过 <ReuseLabel /> 来使用定义的组件
|
||||||
|
* DefineLabel 里边的 v-slot="{ label, hint, hintClick }"相当于是解构了组件的 prop,需要注意的是 boolean 类型,需要显式的赋值比如 <ReuseLabel :flag="true" />
|
||||||
|
* 事件也得以 prop 形式传入,不能是 @event的形式,比如下面的 hintClick 需要<ReuseLabel :hintClick="() => { doSomething }"/>
|
||||||
|
*
|
||||||
|
* @see https://vueuse.org/createReusableTemplate
|
||||||
|
*/
|
||||||
|
const [DefineLabel, ReuseLabel] = createReusableTemplate<{
|
||||||
|
label: string
|
||||||
|
class?: string
|
||||||
|
hint?: string
|
||||||
|
hintClick?: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const initData: WriteVO = {
|
||||||
|
type: 1,
|
||||||
|
prompt: '',
|
||||||
|
originalContent: '',
|
||||||
|
tone: 1,
|
||||||
|
language: 1,
|
||||||
|
length: 1,
|
||||||
|
format: 1
|
||||||
|
}
|
||||||
|
const formData = ref<WriteVO>({ ...initData })
|
||||||
|
|
||||||
|
/** 用来记录切换之前所填写的数据,切换的时候给赋值回来 **/
|
||||||
|
const recordFormData = {} as Record<AiWriteTypeEnum, WriteVO>
|
||||||
|
|
||||||
|
/** 切换tab **/
|
||||||
|
const switchTab = (value: TabType) => {
|
||||||
|
if (value !== selectedTab.value) {
|
||||||
|
// 保存之前的久数据
|
||||||
|
recordFormData[selectedTab.value] = formData.value
|
||||||
|
selectedTab.value = value
|
||||||
|
// 将之前的旧数据赋值回来
|
||||||
|
formData.value = { ...initData, ...recordFormData[value] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交写作 */
|
||||||
|
const submit = () => {
|
||||||
|
if (selectedTab.value === 2 && !formData.value.originalContent) {
|
||||||
|
message.warning('请输入原文')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formData.value.prompt) {
|
||||||
|
message.warning(`请输入${selectedTab.value === 1 ? '写作' : '回复'}内容`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emits('submit', {
|
||||||
|
/** 撰写的时候没有 originalContent 字段**/
|
||||||
|
...(selectedTab.value === 1 ? omit(formData.value, ['originalContent']) : formData.value),
|
||||||
|
/** 使用选中 tab 值覆盖当前的 type 类型 **/
|
||||||
|
type: selectedTab.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,76 @@
|
||||||
|
<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: (...err) => {
|
||||||
|
console.error('写作异常', ...err)
|
||||||
|
stopStream()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击示例触发 */
|
||||||
|
const handleExampleClick = (type: keyof typeof WriteExample) => {
|
||||||
|
writeResult.value = WriteExample[type].data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击重置的时候清空写作的结果**/
|
||||||
|
const reset = () => {
|
||||||
|
writeResult.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -0,0 +1,256 @@
|
||||||
|
<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="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-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
@click="openForm('create')"
|
||||||
|
v-hasPermi="['ai:write:create']"
|
||||||
|
>
|
||||||
|
<Icon icon="ep:plus" class="mr-5px" /> 新增
|
||||||
|
</el-button>
|
||||||
|
<!-- TODO @YunaiV 目前没有导出接口,需要导出吗 -->
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
@click="handleExport"
|
||||||
|
:loading="exportLoading"
|
||||||
|
v-hasPermi="['ai:write:export']"
|
||||||
|
>
|
||||||
|
<Icon icon="ep:download" 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">
|
||||||
|
<!-- TODO @YunaiV 目前没有修改接口,写作要可以更改吗-->
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openForm('update', scope.row.id)"
|
||||||
|
v-hasPermi="['ai:write:update']"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<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 openForm = (type: string, id?: number) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'create':
|
||||||
|
router.push('/ai/write')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除按钮操作 */
|
||||||
|
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>
|
|
@ -126,7 +126,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
|
||||||
import { dateFormatter } from '@/utils/formatTime'
|
import { dateFormatter } from '@/utils/formatTime'
|
||||||
import download from '@/utils/download'
|
|
||||||
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
import { CategoryApi, CategoryVO } from '@/api/bpm/category'
|
||||||
import CategoryForm from './CategoryForm.vue'
|
import CategoryForm from './CategoryForm.vue'
|
||||||
|
|
||||||
|
|
|
@ -36,9 +36,9 @@
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="审批结果" prop="result">
|
<el-form-item label="审批结果" prop="status">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="queryParams.result"
|
v-model="queryParams.status"
|
||||||
class="!w-240px"
|
class="!w-240px"
|
||||||
clearable
|
clearable
|
||||||
placeholder="请选择审批结果"
|
placeholder="请选择审批结果"
|
||||||
|
@ -81,7 +81,7 @@
|
||||||
<ContentWrap>
|
<ContentWrap>
|
||||||
<el-table v-loading="loading" :data="list">
|
<el-table v-loading="loading" :data="list">
|
||||||
<el-table-column align="center" label="申请编号" prop="id" />
|
<el-table-column align="center" label="申请编号" prop="id" />
|
||||||
<el-table-column align="center" label="状态" prop="result">
|
<el-table-column align="center" label="状态" prop="status">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" />
|
<dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" />
|
||||||
</template>
|
</template>
|
||||||
|
|