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_DEFAULT_LOGIN_TENANT = 芋道源码 | ||||
| VITE_APP_DEFAULT_LOGIN_USERNAME = admin | ||||
| VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123 | ||||
|  |  | |||
|  | @ -2,7 +2,6 @@ node_modules | |||
| .DS_Store | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
| /dist* | ||||
| pnpm-debug | ||||
| auto-*.d.ts | ||||
|  |  | |||
| After Width: | Height: | Size: 21 KiB | 
| After Width: | Height: | Size: 348 KiB | 
							
								
								
									
										35
									
								
								README.md
								
								
								
								
							
							
						
						|  | @ -54,17 +54,16 @@ | |||
| 
 | ||||
| 推荐 VS Code 开发,配合插件如下: | ||||
| 
 | ||||
| | 插件名                           | 功能                       | | ||||
| |-------------------------------|--------------------------| | ||||
| | TypeScript Vue Plugin (Volar) | 用于 TypeScript 的 Vue 插件  | | ||||
| | Vue Language Features (Volar) | Vue3.0 语法支持              | | ||||
| | unocss                        | unocss for vscode           | | ||||
| | Iconify IntelliSense          | Iconify 预览和搜索           | | ||||
| | i18n Ally                     | 国际化智能提示               | | ||||
| | Stylelint                     | Css    格式化               | | ||||
| | Prettier                      | 代码格式化                   | | ||||
| | ESLint                        | 脚本代码检查                  | | ||||
| | DotENV                        | env 文件高亮                 | | ||||
| | 插件名                           | 功能                  | | ||||
| |-------------------------------|---------------------| | ||||
| | Vue - Official                | Vue 与 TypeScript 支持 | | ||||
| | unocss                        | unocss for vscode   | | ||||
| | Iconify IntelliSense          | Iconify 预览和搜索       | | ||||
| | i18n Ally                     | 国际化智能提示             | | ||||
| | Stylelint                     | Css    格式化          | | ||||
| | Prettier                      | 代码格式化               | | ||||
| | ESLint                        | 脚本代码检查              | | ||||
| | DotENV                        | env 文件高亮            | | ||||
| 
 | ||||
| ## 🔥 后端架构 | ||||
| 
 | ||||
|  | @ -192,26 +191,24 @@ ps:核心功能已经实现,正在对接微信小程序中... | |||
| 
 | ||||
| ### 商城系统 | ||||
| 
 | ||||
| 演示地址:<https://doc.iocoder.cn/mall-preview/> | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| _前端基于 crmeb uniapp 经过授权重构,优化代码实现,接入芋道快速开发平台_ | ||||
| 
 | ||||
| 演示地址:<https://doc.iocoder.cn/mall-preview/> | ||||
| 
 | ||||
| ### ERP 系统 | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| 演示地址:<https://doc.iocoder.cn/erp-preview/> | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| ### CRM 系统 | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| 演示地址:<https://doc.iocoder.cn/crm-preview/> | ||||
| 
 | ||||
|  | ||||
| 
 | ||||
| ## 🐷 演示图 | ||||
| 
 | ||||
| ### 系统功能 | ||||
|  |  | |||
|  | @ -27,6 +27,12 @@ const include = [ | |||
|   'echarts-wordcloud', | ||||
|   '@wangeditor/editor', | ||||
|   '@wangeditor/editor-for-vue', | ||||
|   '@microsoft/fetch-event-source', | ||||
|   'markdown-it', | ||||
|   'markmap-view', | ||||
|   'markmap-lib', | ||||
|   'markmap-toolbar', | ||||
|   'highlight.js', | ||||
|   'element-plus', | ||||
|   'element-plus/es', | ||||
|   '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-item/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'] | ||||
|  |  | |||
							
								
								
									
										10
									
								
								package.json
								
								
								
								
							
							
						
						|  | @ -1,6 +1,6 @@ | |||
| { | ||||
|   "name": "yudao-ui-admin-vue3", | ||||
|   "version": "2.1.0-snapshot", | ||||
|   "version": "2.2.0-snapshot", | ||||
|   "description": "基于vue3、vite4、element-plus、typesScript", | ||||
|   "author": "xingyu", | ||||
|   "private": false, | ||||
|  | @ -52,7 +52,11 @@ | |||
|     "highlight.js": "^11.9.0", | ||||
|     "jsencrypt": "^3.3.2", | ||||
|     "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", | ||||
|     "mitt": "^3.0.1", | ||||
|     "nprogress": "^0.2.0", | ||||
|  | @ -85,8 +89,8 @@ | |||
|     "@types/qs": "^6.9.12", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.1.0", | ||||
|     "@typescript-eslint/parser": "^7.1.0", | ||||
|     "@unocss/transformer-variant-group": "^0.58.5", | ||||
|     "@unocss/eslint-config": "^0.57.4", | ||||
|     "@unocss/transformer-variant-group": "^0.58.5", | ||||
|     "@vitejs/plugin-legacy": "^5.3.1", | ||||
|     "@vitejs/plugin-vue": "^5.0.4", | ||||
|     "@vitejs/plugin-vue-jsx": "^3.1.0", | ||||
|  |  | |||
							
								
								
									
										12574
									
								
								pnpm-lock.yaml
								
								
								
								
							
							
						
						|  | @ -2,7 +2,7 @@ import request from '@/config/axios' | |||
| 
 | ||||
| // AI 聊天对话 VO
 | ||||
| export interface ChatConversationVO { | ||||
|   id: string // ID 编号
 | ||||
|   id: number // ID 编号
 | ||||
|   userId: number // 用户编号
 | ||||
|   title: string // 对话标题
 | ||||
|   pinned: boolean // 是否置顶
 | ||||
|  | @ -12,6 +12,7 @@ export interface ChatConversationVO { | |||
|   temperature: number // 温度参数
 | ||||
|   maxTokens: number // 单条回复的最大 Token 数量
 | ||||
|   maxContexts: number // 上下文的最大 Message 数量
 | ||||
|   createTime?: Date // 创建时间
 | ||||
|   // 额外字段
 | ||||
|   systemMessage?: string // 角色设定
 | ||||
|   modelName?: string // 模型名字
 | ||||
|  | @ -23,7 +24,7 @@ export interface ChatConversationVO { | |||
| // AI 聊天对话 API
 | ||||
| export const ChatConversationApi = { | ||||
|   // 获得【我的】聊天对话
 | ||||
|   getChatConversationMy: async (id: string) => { | ||||
|   getChatConversationMy: async (id: number) => { | ||||
|     return await request.get({ url: `/ai/chat/conversation/get-my?id=${id}` }) | ||||
|   }, | ||||
| 
 | ||||
|  | @ -43,8 +44,8 @@ export const ChatConversationApi = { | |||
|   }, | ||||
| 
 | ||||
|   // 删除【我的】所有对话,置顶除外
 | ||||
|   deleteMyAllExceptPinned: async () => { | ||||
|     return await request.delete({ url: `/ai/chat/conversation/delete-my-all-except-pinned` }) | ||||
|   deleteChatConversationMyByUnpinned: async () => { | ||||
|     return await request.delete({ url: `/ai/chat/conversation/delete-by-unpinned` }) | ||||
|   }, | ||||
| 
 | ||||
|   // 获得【我的】聊天对话列表
 | ||||
|  |  | |||
|  | @ -19,23 +19,18 @@ export interface ChatMessageVO { | |||
|   userAvatar: string // 创建时间
 | ||||
| } | ||||
| 
 | ||||
| export interface ChatMessageSendVO { | ||||
|   conversationId: string // 对话编号
 | ||||
|   content: number // 聊天内容
 | ||||
| } | ||||
| 
 | ||||
| // AI chat 聊天
 | ||||
| export const ChatMessageApi = { | ||||
|   // 消息列表
 | ||||
|   messageList: async (conversationId: string | null) => { | ||||
|   getChatMessageListByConversationId: async (conversationId: number | null) => { | ||||
|     return await request.get({ | ||||
|       url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}` | ||||
|     }) | ||||
|   }, | ||||
| 
 | ||||
|   // 发送 send stream 消息
 | ||||
|   // TODO axios 可以么? https://apifox.com/apiskills/how-to-create-axios-stream/
 | ||||
|   sendStream: async ( | ||||
|   // 发送 Stream 消息
 | ||||
|   // 为什么不用 axios 呢?因为它不支持 SSE 调用
 | ||||
|   sendChatMessageStream: async ( | ||||
|     conversationId: number, | ||||
|     content: string, | ||||
|     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}` }) | ||||
|   }, | ||||
| 
 | ||||
|   // 删除消息 - 对话所有消息
 | ||||
|   deleteByConversationId: async (conversationId: string) => { | ||||
|   // 删除指定对话的消息
 | ||||
|   deleteByConversationId: async (conversationId: number) => { | ||||
|     return await request.delete({ | ||||
|       url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}` | ||||
|     }) | ||||
|  |  | |||
|  | @ -1,49 +1,22 @@ | |||
| import request from '@/config/axios' | ||||
| 
 | ||||
| // AI API 密钥 VO
 | ||||
| // TODO @fan:要不前端不弄太多 VO,就用这个 ImageDetailVO?!
 | ||||
| export interface ImageDetailVO { | ||||
| // AI 绘图 VO
 | ||||
| export interface ImageVO { | ||||
|   id: number // 编号
 | ||||
|   prompt: string // 提示词
 | ||||
|   status: number // 状态
 | ||||
|   errorMessage: string // 错误信息
 | ||||
|   type: string // 模型下分不同的类型(清晰、真实...)
 | ||||
|   taskId: number // dr 任务id
 | ||||
|   picUrl: string // 任务地址
 | ||||
|   originalPicUrl: string // 绘制图片地址
 | ||||
|   platform: 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 // 提示词
 | ||||
|   model: string // 模型
 | ||||
|   style: string // 图像生成的风格
 | ||||
|   width: string // 图片宽度
 | ||||
|   height: string // 图片高度
 | ||||
|   width: number // 图片宽度
 | ||||
|   height: number // 图片高度
 | ||||
|   status: number // 状态
 | ||||
|   publicStatus: boolean // 公开状态
 | ||||
|   picUrl: string // 任务地址
 | ||||
|   errorMessage: string // 错误信息
 | ||||
|   options: any // 配置 Map<string, string>
 | ||||
|   taskId: number // 任务编号
 | ||||
|   buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮
 | ||||
|   createTime: Date // 创建时间
 | ||||
|   finishTime: Date // 完成时间
 | ||||
| } | ||||
| 
 | ||||
| export interface ImageDrawReqVO { | ||||
|  | @ -65,34 +38,66 @@ export interface ImageMidjourneyImagineReqVO { | |||
|   version: string // 版本
 | ||||
| } | ||||
| 
 | ||||
| // TODO 芋艿:review 下整体注释、方法名
 | ||||
| // AI API 密钥 API
 | ||||
| export interface ImageMidjourneyActionVO { | ||||
|   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 = { | ||||
|   // 获取 image 列表
 | ||||
|   getImageList: async (params: ImagePageReqVO) => { | ||||
|   // 获取【我的】绘图分页
 | ||||
|   getImagePageMy: async (params: any) => { | ||||
|     return await request.get({ url: `/ai/image/my-page`, params }) | ||||
|   }, | ||||
|   // 获取 image 详细信息
 | ||||
|   getImageDetail: async (id: number) => { | ||||
|     return await request.get({ url: `/ai/image/get-my?id=${id}`}) | ||||
|   // 获取【我的】绘图记录
 | ||||
|   getImageMy: async (id: number) => { | ||||
|     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 }) | ||||
|   }, | ||||
|   // 删除
 | ||||
|   deleteImage: async (id: number)=> { | ||||
|     return await request.delete({ url: `/ai/image/delete-my?id=${id}`}) | ||||
|   // 删除【我的】绘画记录
 | ||||
|   deleteImageMy: async (id: number) => { | ||||
|     return await request.delete({ url: `/ai/image/delete-my?id=${id}` }) | ||||
|   }, | ||||
| 
 | ||||
|   // ------------ midjourney
 | ||||
|   // ================ midjourney 专属 ================
 | ||||
| 
 | ||||
|   // midjourney - imagine
 | ||||
|   midjourneyImagine: async (data: ImageMidjourneyImagineReqVO)=> { | ||||
|   // 【Midjourney】生成图片
 | ||||
|   midjourneyImagine: async (data: ImageMidjourneyImagineReqVO) => { | ||||
|     return await request.post({ url: `/ai/image/midjourney/imagine`, data }) | ||||
|   }, | ||||
|   // midjourney - action
 | ||||
|   midjourneyAction: async (params: ImageMjActionVO)=> { | ||||
|     return await request.get({ url: `/ai/image/midjourney/action`, params }) | ||||
|   // 【Midjourney】Action 操作(二次生成图片)
 | ||||
|   midjourneyAction: async (data: ImageMidjourneyActionVO) => { | ||||
|     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' | ||||
| 
 | ||||
| export const getProcessDefinition = async (id: number, key: string) => { | ||||
| export const getProcessDefinition = async (id?: string, key?: string) => { | ||||
|   return await request.get({ | ||||
|     url: '/bpm/process-definition/get', | ||||
|     params: { id, key } | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ export type ProcessDefinitionVO = { | |||
|   version: number | ||||
|   deploymentTIme: string | ||||
|   suspensionState: number | ||||
|   formType?: number | ||||
| } | ||||
| 
 | ||||
| export type ModelVO = { | ||||
|  |  | |||
|  | @ -1,4 +1,5 @@ | |||
| import request from '@/config/axios' | ||||
| import { ProcessDefinitionVO } from '@/api/bpm/model' | ||||
| 
 | ||||
| export type Task = { | ||||
|   id: string | ||||
|  | @ -18,17 +19,7 @@ export type ProcessInstanceVO = { | |||
|   businessKey: string | ||||
|   createTime: string | ||||
|   endTime: string | ||||
| } | ||||
| 
 | ||||
| export type ProcessInstanceCopyVO = { | ||||
|   type: number | ||||
|   taskName: string | ||||
|   taskKey: string | ||||
|   processInstanceName: string | ||||
|   processInstanceKey: string | ||||
|   startUserId: string | ||||
|   options: string[] | ||||
|   reason: string | ||||
|   processDefinition?: ProcessDefinitionVO | ||||
| } | ||||
| 
 | ||||
| export const getProcessInstanceMyPage = async (params: any) => { | ||||
|  |  | |||
|  | @ -12,6 +12,7 @@ export interface JobLogVO { | |||
|   duration: string | ||||
|   status: number | ||||
|   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 | ||||
|     :namespace="variables.elNamespace" | ||||
|     :locale="currentLocale.elLocale" | ||||
|     :message="{ max: 1 }" | ||||
|     :message="{ max: 5 }" | ||||
|     :size="size" | ||||
|   > | ||||
|     <slot></slot> | ||||
|  |  | |||
|  | @ -10,12 +10,13 @@ const prefixCls = getPrefixCls('content-wrap') | |||
| 
 | ||||
| defineProps({ | ||||
|   title: propTypes.string.def(''), | ||||
|   message: propTypes.string.def('') | ||||
|   message: propTypes.string.def(''), | ||||
|   bodyStyle: propTypes.object.def({ padding: '20px' }) | ||||
| }) | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <ElCard :class="[prefixCls, 'mb-15px']" shadow="never"> | ||||
|   <ElCard :body-style="bodyStyle" :class="[prefixCls, 'mb-15px']" shadow="never"> | ||||
|     <template v-if="title" #header> | ||||
|       <div class="flex items-center"> | ||||
|         <span class="text-16px font-700">{{ title }}</span> | ||||
|  | @ -30,8 +31,6 @@ defineProps({ | |||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|     <div> | ||||
|       <slot></slot> | ||||
|     </div> | ||||
|     <slot></slot> | ||||
|   </ElCard> | ||||
| </template> | ||||
|  |  | |||
|  | @ -1,8 +1,9 @@ | |||
| <script lang="tsx"> | ||||
| import { defineComponent, PropType, ref } from 'vue' | ||||
| import { defineComponent, PropType, computed } from 'vue' | ||||
| import { isHexColor } from '@/utils/color' | ||||
| import { ElTag } from 'element-plus' | ||||
| import { DictDataType, getDictOptions } from '@/utils/dict' | ||||
| import { isArray, isString, isNumber } from '@/utils/is' | ||||
| 
 | ||||
| export default defineComponent({ | ||||
|   name: 'DictTag', | ||||
|  | @ -12,49 +13,78 @@ export default defineComponent({ | |||
|       required: true | ||||
|     }, | ||||
|     value: { | ||||
|       type: [String, Number, Boolean] as PropType<string | number | boolean>, | ||||
|       type: [String, Number, Boolean, Array], | ||||
|       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) { | ||||
|     const dictData = ref<DictDataType>() | ||||
|     const getDictObj = (dictType: string, value: string) => { | ||||
|       const dictOptions = getDictOptions(dictType) | ||||
|       dictOptions.forEach((dict: DictDataType) => { | ||||
|         if (dict.value === value) { | ||||
|           if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') { | ||||
|             dict.colorType = '' | ||||
|           } | ||||
|           dictData.value = dict | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|     const rederDictTag = () => { | ||||
|     const valueArr: any = computed(() => { | ||||
|       // 1.是Number类型的情况 | ||||
|       if (isNumber(props.value)) { | ||||
|         return [String(props.value)] | ||||
|       } | ||||
|       // 2.是字符串(进一步判断是否有包含分隔符号 -> props.sepSymbol ) | ||||
|       else if (isString(props.value)) { | ||||
|         return props.value.split(props.separator) | ||||
|       } | ||||
|       // 3.数组 | ||||
|       else if (isArray(props.value)) { | ||||
|         return props.value.map(String) | ||||
|       } | ||||
|       return [] | ||||
|     }) | ||||
|     const renderDictTag = () => { | ||||
|       if (!props.type) { | ||||
|         return null | ||||
|       } | ||||
|       // 解决自定义字典标签值为零时标签不渲染的问题 | ||||
|       if (props.value === undefined || props.value === null) { | ||||
|       if (props.value === undefined || props.value === null || props.value === '') { | ||||
|         return null | ||||
|       } | ||||
|       getDictObj(props.type, props.value.toString()) | ||||
|       // 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题 | ||||
|       const dictOptions = getDictOptions(props.type) | ||||
| 
 | ||||
|       return ( | ||||
|         <ElTag | ||||
|           style={dictData.value?.cssClass ? 'color: #fff' : ''} | ||||
|           type={dictData.value?.colorType} | ||||
|           color={ | ||||
|             dictData.value?.cssClass && isHexColor(dictData.value?.cssClass) | ||||
|               ? dictData.value?.cssClass | ||||
|               : '' | ||||
|           } | ||||
|           disableTransitions={true} | ||||
|         <div | ||||
|           class="dict-tag" | ||||
|           style={{ | ||||
|             display: 'flex', | ||||
|             gap: props.gutter, | ||||
|             justifyContent: 'center', | ||||
|             alignItems: 'center' | ||||
|           }} | ||||
|         > | ||||
|           {dictData.value?.label} | ||||
|         </ElTag> | ||||
|           {dictOptions.map((dict: DictDataType) => { | ||||
|             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> | ||||
|  |  | |||
|  | @ -95,6 +95,7 @@ const handleCloneComponent = (component: DiyComponent<any>) => { | |||
| .editor-left { | ||||
|   z-index: 1; | ||||
|   flex-shrink: 0; | ||||
|   user-select: none; | ||||
|   box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%); | ||||
| 
 | ||||
|   :deep(.el-collapse) { | ||||
|  |  | |||
|  | @ -96,11 +96,6 @@ const editorConfig = computed((): IEditorConfig => { | |||
|           // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 [] | ||||
|           allowedFileTypes: ['image/*'], | ||||
| 
 | ||||
|           // 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。 | ||||
|           meta: { updateSupport: 0 }, | ||||
|           // 将 meta 拼接到 url 参数中,默认 false | ||||
|           metaWithUrl: true, | ||||
| 
 | ||||
|           // 自定义增加 http  header | ||||
|           headers: { | ||||
|             Accept: '*', | ||||
|  | @ -108,9 +103,6 @@ const editorConfig = computed((): IEditorConfig => { | |||
|             'tenant-id': getTenantId() | ||||
|           }, | ||||
| 
 | ||||
|           // 跨域是否传递 cookie ,默认为 false | ||||
|           withCredentials: true, | ||||
| 
 | ||||
|           // 超时时间,默认为 10 秒 | ||||
|           timeout: 5 * 1000, // 5 秒 | ||||
| 
 | ||||
|  | @ -119,7 +111,7 @@ const editorConfig = computed((): IEditorConfig => { | |||
| 
 | ||||
|           // 上传之前触发 | ||||
|           onBeforeUpload(file: File) { | ||||
|             console.log(file) | ||||
|             // console.log(file) | ||||
|             return file | ||||
|           }, | ||||
|           // 上传进度的回调函数 | ||||
|  | @ -142,6 +134,54 @@ const editorConfig = computed((): IEditorConfig => { | |||
|           customInsert(res: any, insertFn: InsertFnType) { | ||||
|             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 | ||||
|  |  | |||
|  | @ -1,43 +1,12 @@ | |||
| 
 | ||||
| <template> | ||||
|   <div ref="contentRef" class="markdown-view" v-html="contentHtml"></div> | ||||
|   <div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import {useClipboard} from "@vueuse/core"; | ||||
| 
 | ||||
| import {marked} from 'marked' | ||||
| import { useClipboard } from '@vueuse/core' | ||||
| import MarkdownIt from 'markdown-it' | ||||
| import 'highlight.js/styles/vs2015.min.css' | ||||
| 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({ | ||||
|  | @ -47,39 +16,39 @@ const props = defineProps({ | |||
|   } | ||||
| }) | ||||
| 
 | ||||
| // 将 props 变为引用类型 | ||||
| const { content } = toRefs(props) | ||||
| const message = useMessage() // 消息弹窗 | ||||
| const { copy } = useClipboard() // 初始化 copy 到粘贴板 | ||||
| const contentRef = ref() | ||||
| 
 | ||||
| // 监听 content 变化 | ||||
| watch(content, async (newValue, oldValue) => { | ||||
|   await renderMarkdown(newValue); | ||||
| const md = new MarkdownIt({ | ||||
|   highlight: function (str, lang) { | ||||
|     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 | ||||
| const renderMarkdown = async (content: string) => { | ||||
|   contentHtml.value = await marked(content) | ||||
| } | ||||
| /** 渲染 markdown */ | ||||
| const renderedMarkdown = computed(() => { | ||||
|   return md.render(props.content) | ||||
| }) | ||||
| 
 | ||||
| // 组件挂在时 | ||||
| onMounted(async ()  => { | ||||
|   // 解析转换 markdown | ||||
|   await renderMarkdown(props.content as string); | ||||
|   // | ||||
| /** 初始化 **/ | ||||
| onMounted(async () => { | ||||
|   // 添加 copy 监听 | ||||
|   contentRef.value.addEventListener('click', (e: any) => { | ||||
|     console.log(e) | ||||
|     if (e.target.id === 'copy') { | ||||
|       copy(e.target?.dataset?.copy) | ||||
|       ElMessage({ | ||||
|         message: '复制成功!', | ||||
|         type: 'success' | ||||
|       }) | ||||
|       message.success('复制成功!') | ||||
|     } | ||||
|   }) | ||||
| }) | ||||
| </script> | ||||
| 
 | ||||
| 
 | ||||
| <style lang="scss"> | ||||
| .markdown-view { | ||||
|   font-family: PingFang SC; | ||||
|  |  | |||
|  | @ -32,6 +32,7 @@ | |||
|           格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件 | ||||
|         </div> | ||||
|       </template> | ||||
|       <!-- TODO @puhui999:1)表单展示的时候,位置会偏掉,已发微信;2)disable 的时候,应该把【删除】按钮也隐藏掉? --> | ||||
|       <template #file="row"> | ||||
|         <div class="flex items-center"> | ||||
|           <span>{{ row.file.name }}</span> | ||||
|  |  | |||
|  | @ -129,7 +129,7 @@ const updateFlowType = (flowType) => { | |||
|       conditionExpression: null | ||||
|     }) | ||||
|     bpmnInstances().modeling.updateProperties(toRaw(bpmnElementSource.value), { | ||||
|       default: bpmnElement.value | ||||
|       default: toRaw(bpmnElement.value) | ||||
|     }) | ||||
|     return | ||||
|   } | ||||
|  |  | |||
|  | @ -43,9 +43,6 @@ import { CommonStatusEnum } from '@/utils/constants' | |||
| /** BPM 流程 表单 */ | ||||
| defineOptions({ name: 'ProcessListenerDialog' }) | ||||
| 
 | ||||
| const { t } = useI18n() // 国际化 | ||||
| const message = useMessage() // 消息弹窗 | ||||
| 
 | ||||
| const dialogVisible = ref(false) // 弹窗的是否展示 | ||||
| const loading = ref(true) // 列表的加载中 | ||||
| const list = ref<ProcessListenerVO[]>([]) // 列表的数据 | ||||
|  | @ -53,17 +50,23 @@ const total = ref(0) // 列表的总页数 | |||
| const queryParams = reactive({ | ||||
|   pageNo: 1, | ||||
|   pageSize: 10, | ||||
|   type: undefined, | ||||
|   type: '', | ||||
|   status: CommonStatusEnum.ENABLE | ||||
| }) | ||||
| 
 | ||||
| /** 打开弹窗 */ | ||||
| const open = async (type: string) => { | ||||
|   queryParams.pageNo = 1 | ||||
|   queryParams.type = type | ||||
|   getList() | ||||
|   dialogVisible.value = true | ||||
| } | ||||
| defineExpose({ open }) // 提供 open 方法,用于打开弹窗 | ||||
| 
 | ||||
| /** 查询列表 */ | ||||
| const getList = async () => { | ||||
|   loading.value = true | ||||
|   try { | ||||
|     queryParams.pageNo = 1 | ||||
|     queryParams.type = type | ||||
|     const data = await ProcessListenerApi.getProcessListenerPage(queryParams) | ||||
|     list.value = data.list | ||||
|     total.value = data.total | ||||
|  | @ -71,7 +74,6 @@ const open = async (type: string) => { | |||
|     loading.value = false | ||||
|   } | ||||
| } | ||||
| defineExpose({ open }) // 提供 open 方法,用于打开弹窗 | ||||
| 
 | ||||
| /** 提交表单 */ | ||||
| const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 | ||||
|  |  | |||
|  | @ -28,9 +28,6 @@ import { ProcessExpressionApi, ProcessExpressionVO } from '@/api/bpm/processExpr | |||
| /** BPM 流程 表单 */ | ||||
| defineOptions({ name: 'ProcessExpressionDialog' }) | ||||
| 
 | ||||
| const { t } = useI18n() // 国际化 | ||||
| const message = useMessage() // 消息弹窗 | ||||
| 
 | ||||
| const dialogVisible = ref(false) // 弹窗的是否展示 | ||||
| const loading = ref(true) // 列表的加载中 | ||||
| const list = ref<ProcessExpressionVO[]>([]) // 列表的数据 | ||||
|  | @ -38,17 +35,23 @@ const total = ref(0) // 列表的总页数 | |||
| const queryParams = reactive({ | ||||
|   pageNo: 1, | ||||
|   pageSize: 10, | ||||
|   type: undefined, | ||||
|   type: '', | ||||
|   status: CommonStatusEnum.ENABLE | ||||
| }) | ||||
| 
 | ||||
| /** 打开弹窗 */ | ||||
| const open = async (type: string) => { | ||||
| const open = (type: string) => { | ||||
|   queryParams.pageNo = 1 | ||||
|   queryParams.type = type | ||||
|   getList() | ||||
|   dialogVisible.value = true | ||||
| } | ||||
| defineExpose({ open }) // 提供 open 方法,用于打开弹窗 | ||||
| 
 | ||||
| /** 查询列表 */ | ||||
| const getList = async () => { | ||||
|   loading.value = true | ||||
|   try { | ||||
|     queryParams.pageNo = 1 | ||||
|     queryParams.type = type | ||||
|     const data = await ProcessExpressionApi.getProcessExpressionPage(queryParams) | ||||
|     list.value = data.list | ||||
|     total.value = data.total | ||||
|  | @ -56,7 +59,6 @@ const open = async (type: string) => { | |||
|     loading.value = false | ||||
|   } | ||||
| } | ||||
| defineExpose({ open }) // 提供 open 方法,用于打开弹窗 | ||||
| 
 | ||||
| /** 提交表单 */ | ||||
| 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 UserGroupApi from '@/api/bpm/userGroup' | ||||
| import ProcessExpressionDialog from './ProcessExpressionDialog.vue' | ||||
| import { ProcessExpressionVO } from '@/api/bpm/processExpression' | ||||
| 
 | ||||
| defineOptions({ name: 'UserTask' }) | ||||
| const props = defineProps({ | ||||
|  | @ -197,8 +198,9 @@ const processExpressionDialogRef = ref() | |||
| const openProcessExpressionDialog = async () => { | ||||
|   processExpressionDialogRef.value.open() | ||||
| } | ||||
| const selectProcessExpression = (expression) => { | ||||
| const selectProcessExpression = (expression: ProcessExpressionVO) => { | ||||
|   userTaskForm.value.candidateParam = [expression.expression] | ||||
|   updateElementTask() | ||||
| } | ||||
| 
 | ||||
| watch( | ||||
|  |  | |||
|  | @ -81,7 +81,7 @@ service.interceptors.request.use( | |||
|   (error: AxiosError) => { | ||||
|     // Do something with request error
 | ||||
|     console.log(error) // for debug
 | ||||
|     Promise.reject(error) | ||||
|     return Promise.reject(error) | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
|  | @ -174,6 +174,7 @@ service.interceptors.response.use( | |||
|       if (msg === '无效的刷新令牌') { | ||||
|         // hard coding:忽略这个提示,直接登出
 | ||||
|         console.log(msg) | ||||
|         return handleAuthorized() | ||||
|       } else { | ||||
|         ElNotification.error({ title: msg }) | ||||
|       } | ||||
|  |  | |||
|  | @ -90,6 +90,11 @@ export default defineComponent({ | |||
|           backgroundColor="var(--left-menu-bg-color)" | ||||
|           textColor="var(--left-menu-text-color)" | ||||
|           activeTextColor="var(--left-menu-text-active-color)" | ||||
|           popperClass={ | ||||
|             unref(menuMode) === 'vertical' | ||||
|               ? `${prefixCls}-popper--vertical` | ||||
|               : `${prefixCls}-popper--horizontal` | ||||
|           } | ||||
|           onSelect={menuSelect} | ||||
|         > | ||||
|           {{ | ||||
|  |  | |||
|  | @ -52,7 +52,7 @@ async function goLogin() { | |||
|   // 登出后清理 | ||||
|   deleteUserCache() // 清空用户缓存 | ||||
|   tagsViewStore.delAllViews() | ||||
|   resetRouter() // 重置静态路由表 | ||||
|   // resetRouter() // 重置静态路由表 | ||||
|   lockStore.resetLockInfo() | ||||
|   replace('/login') | ||||
| } | ||||
|  |  | |||
|  | @ -341,7 +341,7 @@ const remainingRouter: AppRouteRecordRaw[] = [ | |||
|         component: () => import('@/views/mall/product/spu/form/index.vue'), | ||||
|         name: 'ProductSpuAdd', | ||||
|         meta: { | ||||
|           noCache: true, | ||||
|           noCache: false, // 需要缓存
 | ||||
|           hidden: true, | ||||
|           canTo: true, | ||||
|           icon: 'ep:edit', | ||||
|  | @ -573,6 +573,26 @@ const remainingRouter: AppRouteRecordRaw[] = [ | |||
|         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', | ||||
|     name: '微信 APP 支付' | ||||
|   }, | ||||
|   WX_NATIVE: { | ||||
|     code: 'wx_native', | ||||
|     name: '微信 Native 支付' | ||||
|   }, | ||||
|   WX_WAP: { | ||||
|     code: 'wx_wap', | ||||
|     name: '微信 WAP 网站支付' | ||||
|   }, | ||||
|   WX_BAR: { | ||||
|     code: 'wx_bar', | ||||
|     name: '微信条码支付' | ||||
|  |  | |||
|  | @ -125,7 +125,6 @@ export enum DICT_TYPE { | |||
|   SYSTEM_SMS_TEMPLATE_TYPE = 'system_sms_template_type', | ||||
|   SYSTEM_SMS_SEND_STATUS = 'system_sms_send_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_MAIL_SEND_STATUS = 'system_mail_send_status', | ||||
|   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', // 库存明细的业务类型
 | ||||
| 
 | ||||
|   // ========== 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) => { | ||||
|     download0(data, fileName, 'text/html') | ||||
|   }, | ||||
|   // 下载 MarkdownView 方法
 | ||||
|   // 下载 Markdown 方法
 | ||||
|   markdown: (data: Blob, fileName: string) => { | ||||
|     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) => { | ||||
|   // 防止除0
 | ||||
|   if (!reference) return 0 | ||||
|   if (!reference || reference == 0) return 0 | ||||
| 
 | ||||
|   return ((100 * ((value || 0) - reference)) / reference).toFixed(0) | ||||
| } | ||||
|  |  | |||
|  | @ -184,9 +184,9 @@ const loginData = reactive({ | |||
|   captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE, | ||||
|   tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE, | ||||
|   loginForm: { | ||||
|     tenantName: '芋道源码', | ||||
|     username: 'admin', | ||||
|     password: 'admin123', | ||||
|     tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '', | ||||
|     username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '', | ||||
|     password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '', | ||||
|     captchaVerification: '', | ||||
|     rememberMe: true // 默认记录我。如果不需要,可手动修改 | ||||
|   } | ||||
|  |  | |||
|  | @ -1,11 +1,10 @@ | |||
| <!--  AI 对话  --> | ||||
| <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"> | ||||
|         <Icon icon="ep:plus" class="mr-5px"/> | ||||
|         <Icon icon="ep:plus" class="mr-5px" /> | ||||
|         新建对话 | ||||
|       </el-button> | ||||
| 
 | ||||
|  | @ -18,17 +17,20 @@ | |||
|         @keyup="searchConversation" | ||||
|       > | ||||
|         <template #prefix> | ||||
|           <Icon icon="ep:search"/> | ||||
|           <Icon icon="ep:search" /> | ||||
|         </template> | ||||
|       </el-input> | ||||
| 
 | ||||
|       <!-- 左中间:对话列表 --> | ||||
|       <div class="conversation-list"> | ||||
| 
 | ||||
|         <!-- 情况一:加载中 --> | ||||
|         <el-empty v-if="loading" description="." :v-loading="loading" /> | ||||
| 
 | ||||
|         <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 --> | ||||
|         <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey"> | ||||
|           <div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length"> | ||||
|           <div | ||||
|             class="conversation-item classify-title" | ||||
|             v-if="conversationMap[conversationKey].length" | ||||
|           > | ||||
|             <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text> | ||||
|           </div> | ||||
|           <div | ||||
|  | @ -40,76 +42,71 @@ | |||
|             @mouseout="hoverConversationId = ''" | ||||
|           > | ||||
|             <div | ||||
|               :class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'" | ||||
|               :class=" | ||||
|                 conversation.id === activeConversationId ? 'conversation active' : 'conversation' | ||||
|               " | ||||
|             > | ||||
|               <div class="title-wrapper"> | ||||
|                 <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg"/> | ||||
|                 <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" /> | ||||
|                 <span class="title">{{ conversation.title }}</span> | ||||
|               </div> | ||||
|               <div class="button-wrapper" v-show="hoverConversationId === conversation.id"> | ||||
|                 <el-button class="btn" link @click.stop="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"><Bottom /></el-icon> | ||||
|                 </el-button> | ||||
|                 <el-button class="btn" link @click.stop="updateConversationTitle(conversation)"> | ||||
|                   <el-icon title="编辑" > | ||||
|                     <Icon icon="ep:edit"/> | ||||
|                   <el-icon title="编辑"> | ||||
|                     <Icon icon="ep:edit" /> | ||||
|                   </el-icon> | ||||
|                 </el-button> | ||||
|                 <el-button class="btn" link @click.stop="deleteChatConversation(conversation)"> | ||||
|                   <el-icon title="删除对话" > | ||||
|                     <Icon icon="ep:delete"/> | ||||
|                   <el-icon title="删除对话"> | ||||
|                     <Icon icon="ep:delete" /> | ||||
|                   </el-icon> | ||||
|                 </el-button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <!--  底部站位  --> | ||||
|         <div style="height: 160px; width: 100%;"></div> | ||||
|         <!-- 底部占位  --> | ||||
|         <div class="h-160px w-100%"></div> | ||||
|       </div> | ||||
| 
 | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- 左底部:工具栏 --> | ||||
|     <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 --> | ||||
|     <div class="tool-box"> | ||||
|       <div @click="handleRoleRepository"> | ||||
|         <Icon icon="ep:user"/> | ||||
|         <Icon icon="ep:user" /> | ||||
|         <el-text size="small">角色仓库</el-text> | ||||
|       </div> | ||||
|       <div @click="handleClearConversation"> | ||||
|         <Icon icon="ep:delete"/> | ||||
|         <Icon icon="ep:delete" /> | ||||
|         <el-text size="small">清空未置顶对话</el-text> | ||||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- ============= 额外组件 ============= --> | ||||
| 
 | ||||
|     <!-- 角色仓库抽屉 --> | ||||
|     <el-drawer v-model="drawer" title="角色仓库" size="754px"> | ||||
|       <Role/> | ||||
|     <el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px"> | ||||
|       <RoleRepository /> | ||||
|     </el-drawer> | ||||
| 
 | ||||
|   </el-aside> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation' | ||||
| import {ref} from "vue"; | ||||
| import Role from "@/views/ai/chat/role/index.vue"; | ||||
| import {Bottom, Top} from "@element-plus/icons-vue"; | ||||
| import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' | ||||
| import RoleRepository from '../role/RoleRepository.vue' | ||||
| import { Bottom, Top } from '@element-plus/icons-vue' | ||||
| import roleAvatarDefaultImg from '@/assets/ai/gpt.svg' | ||||
| 
 | ||||
| const message = useMessage() // 消息弹窗 | ||||
| 
 | ||||
| // 定义属性 | ||||
| const searchName = ref<string>('') // 对话搜索 | ||||
| const activeConversationId = ref<string | null>(null) // 选中的对话,默认为 null | ||||
| const hoverConversationId = ref<string | null>(null) // 悬浮上去的对话 | ||||
| const conversationList = ref([] as ChatConversationVO[])  // 对话列表 | ||||
| const conversationMap = ref<any>({})  // 对话分组 (置顶、今天、三天前、一星期前、一个月前) | ||||
| const drawer = ref<boolean>(false) // 角色仓库抽屉 TODO @fan:roleDrawer 会不会好点哈 | ||||
| const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null | ||||
| const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话 | ||||
| const conversationList = ref([] as ChatConversationVO[]) // 对话列表 | ||||
| const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前) | ||||
| const loading = ref<boolean>(false) // 加载中 | ||||
| const loadingTime = ref<any>() // 加载中定时器 | ||||
| 
 | ||||
|  | @ -129,75 +126,58 @@ const emits = defineEmits([ | |||
|   'onConversationDelete' | ||||
| ]) | ||||
| 
 | ||||
| /** | ||||
|  * 对话 - 搜索 | ||||
|  */ | ||||
| /** 搜索对话 */ | ||||
| const searchConversation = async (e) => { | ||||
|   // 恢复数据 | ||||
|   if (!searchName.value.trim().length) { | ||||
|     conversationMap.value = await conversationTimeGroup(conversationList.value) | ||||
|     conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) | ||||
|   } else { | ||||
|     // 过滤 | ||||
|     const filterValues = conversationList.value.filter(item => { | ||||
|     const filterValues = conversationList.value.filter((item) => { | ||||
|       return item.title.includes(searchName.value.trim()) | ||||
|     }) | ||||
|     conversationMap.value = await conversationTimeGroup(filterValues) | ||||
|     conversationMap.value = await getConversationGroupByCreateTime(filterValues) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 对话 - 点击 | ||||
|  */ | ||||
| const handleConversationClick = async (id: string) => { | ||||
| /** 点击对话 */ | ||||
| const handleConversationClick = async (id: number) => { | ||||
|   // 过滤出选中的对话 | ||||
|   const filterConversation = conversationList.value.filter(item => { | ||||
|   const filterConversation = conversationList.value.filter((item) => { | ||||
|     return item.id === id | ||||
|   }) | ||||
|   // 回调 onConversationClick | ||||
|   // TODO @fan: 这里 idea 会报黄色警告,有办法解下么? | ||||
|   const res = emits('onConversationClick', filterConversation[0]) | ||||
|   // noinspection JSVoidFunctionReturnValueUsed | ||||
|   const success = emits('onConversationClick', filterConversation[0]) | ||||
|   // 切换对话 | ||||
|   if (res) { | ||||
|   if (success) { | ||||
|     activeConversationId.value = id | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 对话 - 获取列表 | ||||
|  */ | ||||
| /** 获取对话列表 */ | ||||
| const getChatConversationList = async () => { | ||||
|   try { | ||||
|     // 0. 加载中 | ||||
|     // 加载中 | ||||
|     loadingTime.value = setTimeout(() => { | ||||
|       loading.value = true | ||||
|     }, 50) | ||||
|     // 1. 获取 对话数据 | ||||
|     const res = await ChatConversationApi.getChatConversationMyList() | ||||
|     // 2. 排序 | ||||
|     res.sort((a, b) => { | ||||
| 
 | ||||
|     // 1.1 获取 对话数据 | ||||
|     conversationList.value = await ChatConversationApi.getChatConversationMyList() | ||||
|     // 1.2 排序 | ||||
|     conversationList.value.sort((a, b) => { | ||||
|       return b.createTime - a.createTime | ||||
|     }) | ||||
|     conversationList.value = res | ||||
|     // 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. 没有任何对话情况 | ||||
|     // 1.3 没有任何对话情况 | ||||
|     if (conversationList.value.length === 0) { | ||||
|       activeConversationId.value = null | ||||
|       conversationMap.value = {} | ||||
|       return | ||||
|     } | ||||
|     // 5. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30天前) | ||||
|     conversationMap.value = await conversationTimeGroup(conversationList.value) | ||||
| 
 | ||||
|     // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前) | ||||
|     conversationMap.value = await getConversationGroupByCreateTime(conversationList.value) | ||||
|   } finally { | ||||
|     // 清理定时器 | ||||
|     if (loadingTime.value) { | ||||
|  | @ -208,31 +188,33 @@ const getChatConversationList = async () => { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| const conversationTimeGroup = async (list: ChatConversationVO[]) => { | ||||
| /** 按照 creteTime 创建时间,进行分组 */ | ||||
| const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => { | ||||
|   // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前) | ||||
|   // noinspection NonAsciiCharacters | ||||
|   const groupMap = { | ||||
|     '置顶': [], | ||||
|     '今天': [], | ||||
|     '一天前': [], | ||||
|     '三天前': [], | ||||
|     '七天前': [], | ||||
|     '三十天前': [] | ||||
|     置顶: [], | ||||
|     今天: [], | ||||
|     一天前: [], | ||||
|     三天前: [], | ||||
|     七天前: [], | ||||
|     三十天前: [] | ||||
|   } | ||||
|   // 当前时间的时间戳 | ||||
|   const now = Date.now(); | ||||
|   const now = Date.now() | ||||
|   // 定义时间间隔常量(单位:毫秒) | ||||
|   const oneDay = 24 * 60 * 60 * 1000; | ||||
|   const threeDays = 3 * oneDay; | ||||
|   const sevenDays = 7 * oneDay; | ||||
|   const thirtyDays = 30 * oneDay; | ||||
|   for (const conversation: ChatConversationVO of list) { | ||||
|   const oneDay = 24 * 60 * 60 * 1000 | ||||
|   const threeDays = 3 * oneDay | ||||
|   const sevenDays = 7 * oneDay | ||||
|   const thirtyDays = 30 * oneDay | ||||
|   for (const conversation of list) { | ||||
|     // 置顶 | ||||
|     if (conversation.pinned) { | ||||
|       groupMap['置顶'].push(conversation) | ||||
|       continue | ||||
|     } | ||||
|     // 计算时间差(单位:毫秒) | ||||
|     const diff = now - conversation.updateTime; | ||||
|     const diff = now - conversation.createTime | ||||
|     // 根据时间间隔判断 | ||||
|     if (diff < oneDay) { | ||||
|       groupMap['今天'].push(conversation) | ||||
|  | @ -246,13 +228,10 @@ const conversationTimeGroup = async (list: ChatConversationVO[]) => { | |||
|       groupMap['三十天前'].push(conversation) | ||||
|     } | ||||
|   } | ||||
|   console.log('----groupMap', groupMap) | ||||
|   return groupMap | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 对话 - 新建 | ||||
|  */ | ||||
| /** 新建对话 */ | ||||
| const createConversation = async () => { | ||||
|   // 1. 新建对话 | ||||
|   const conversationId = await ChatConversationApi.createChatConversationMy( | ||||
|  | @ -266,12 +245,10 @@ const createConversation = async () => { | |||
|   emits('onConversationCreate') | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 对话 - 更新标题 | ||||
|  */ | ||||
| /** 修改对话的标题 */ | ||||
| const updateConversationTitle = async (conversation: ChatConversationVO) => { | ||||
|   // 1. 二次确认 | ||||
|   const {value} = await ElMessageBox.prompt('修改标题', { | ||||
|   const { value } = await ElMessageBox.prompt('修改标题', { | ||||
|     inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格 | ||||
|     inputErrorMessage: '标题不能为空', | ||||
|     inputValue: conversation.title | ||||
|  | @ -285,7 +262,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => { | |||
|   // 3. 刷新列表 | ||||
|   await getChatConversationList() | ||||
|   // 4. 过滤当前切换的 | ||||
|   const filterConversationList = conversationList.value.filter(item => { | ||||
|   const filterConversationList = conversationList.value.filter((item) => { | ||||
|     return item.id === conversation.id | ||||
|   }) | ||||
|   if (filterConversationList.length > 0) { | ||||
|  | @ -296,9 +273,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 删除聊天对话 | ||||
|  */ | ||||
| /** 删除聊天对话 */ | ||||
| const deleteChatConversation = async (conversation: ChatConversationVO) => { | ||||
|   try { | ||||
|     // 删除的二次确认 | ||||
|  | @ -310,15 +285,29 @@ const deleteChatConversation = async (conversation: ChatConversationVO) => { | |||
|     await getChatConversationList() | ||||
|     // 回调 | ||||
|     emits('onConversationDelete', conversation) | ||||
|   } catch { | ||||
|   } | ||||
|   } catch {} | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 对话置顶 | ||||
|  */ | ||||
| // TODO @fan:应该是 handleXXX,handler 是名词哈 | ||||
| const handlerTop = async (conversation: ChatConversationVO) => { | ||||
| /** 清空对话 */ | ||||
| const handleClearConversation = async () => { | ||||
|   try { | ||||
|     await message.confirm('确认后对话会全部清空,置顶的对话除外。') | ||||
|     await ChatConversationApi.deleteChatConversationMyByUnpinned() | ||||
|     ElMessage({ | ||||
|       message: '操作成功!', | ||||
|       type: 'success' | ||||
|     }) | ||||
|     // 清空 对话 和 对话内容 | ||||
|     activeConversationId.value = null | ||||
|     // 获取 对话列表 | ||||
|     await getChatConversationList() | ||||
|     // 回调 方法 | ||||
|     emits('onConversationClear') | ||||
|   } catch {} | ||||
| } | ||||
| 
 | ||||
| /** 对话置顶 */ | ||||
| const handleTop = async (conversation: ChatConversationVO) => { | ||||
|   // 更新对话置顶 | ||||
|   conversation.pinned = !conversation.pinned | ||||
|   await ChatConversationApi.updateChatConversationMy(conversation) | ||||
|  | @ -326,64 +315,29 @@ const handlerTop = async (conversation: ChatConversationVO) => { | |||
|   await getChatConversationList() | ||||
| } | ||||
| 
 | ||||
| // TODO @fan:类似 ============ 分块的,最后后面也有 ============ 哈 | ||||
| // ============ 角色仓库 | ||||
| // ============ 角色仓库 ============ | ||||
| 
 | ||||
| /** | ||||
|  * 角色仓库抽屉 | ||||
|  */ | ||||
| /** 角色仓库抽屉 */ | ||||
| const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开 | ||||
| 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) | ||||
| watch(activeId, async (newValue, oldValue) => { | ||||
|   // 更新选中 | ||||
|   activeConversationId.value = newValue as string | ||||
| }) | ||||
| 
 | ||||
| // 定义 public 方法 | ||||
| defineExpose({createConversation}) | ||||
| defineExpose({ createConversation }) | ||||
| 
 | ||||
| /** 初始化 */ | ||||
| onMounted(async () => { | ||||
|   // 获取 对话列表 | ||||
|   await getChatConversationList() | ||||
|   // 默认选中 | ||||
|   if (props.activeId != null) { | ||||
|   if (props.activeId) { | ||||
|     activeConversationId.value = props.activeId | ||||
|   } else { | ||||
|     // 首次默认选中第一个 | ||||
|  | @ -394,18 +348,15 @@ onMounted(async () => { | |||
|     } | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <style scoped lang="scss"> | ||||
| 
 | ||||
| .conversation-container { | ||||
|   position: relative; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: space-between; | ||||
|   padding: 0 10px; | ||||
|   padding-top: 10px; | ||||
|   padding: 10px 10px 0; | ||||
|   overflow: hidden; | ||||
| 
 | ||||
|   .btn-new-conversation { | ||||
|  | @ -8,7 +8,12 @@ | |||
|       v-loading="formLoading" | ||||
|     > | ||||
|       <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 label="模型" prop="modelId"> | ||||
|         <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 { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' | ||||
| 
 | ||||
| /** AI 聊天角色 表单 */ | ||||
| /** AI 聊天对话的更新表单 */ | ||||
| defineOptions({ name: 'ChatConversationUpdateForm' }) | ||||
| 
 | ||||
| const { t } = useI18n() // 国际化 | ||||
| const message = useMessage() // 消息弹窗 | ||||
| 
 | ||||
| const dialogVisible = ref(false) // 弹窗的是否展示 | ||||
|  | @ -1,7 +1,7 @@ | |||
| <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"> | ||||
|       <!--  靠左 message  --> | ||||
|       <!-- 靠左 message:system、assistant 类型 --> | ||||
|       <div class="left-message message-item" v-if="item.type !== 'user'"> | ||||
|         <div class="avatar"> | ||||
|           <el-avatar :src="roleAvatar" /> | ||||
|  | @ -14,16 +14,16 @@ | |||
|             <MarkdownView class="left-text" :content="item.content" /> | ||||
|           </div> | ||||
|           <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" /> | ||||
|             </el-button> | ||||
|             <el-button class="btn-cus" link @click="onDelete(item.id)"> | ||||
|               <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px" /> | ||||
|             <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)"> | ||||
|               <img class="btn-image h-17px" src="@/assets/ai/delete.svg" /> | ||||
|             </el-button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <!--  靠右 message  --> | ||||
|       <!-- 靠右 message:user 类型 --> | ||||
|       <div class="right-message message-item" v-if="item.type === 'user'"> | ||||
|         <div class="avatar"> | ||||
|           <el-avatar :src="userAvatar" /> | ||||
|  | @ -36,15 +36,11 @@ | |||
|             <div class="right-text">{{ item.content }}</div> | ||||
|           </div> | ||||
|           <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" /> | ||||
|             </el-button> | ||||
|             <el-button class="btn-cus" link @click="onDelete(item.id)"> | ||||
|               <img | ||||
|                 class="btn-image" | ||||
|                 src="@/assets/ai/delete.svg" | ||||
|                 style="height: 17px; margin-right: 12px" | ||||
|               /> | ||||
|               <img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" /> | ||||
|             </el-button> | ||||
|             <el-button class="btn-cus" link @click="onRefresh(item)"> | ||||
|               <el-icon size="17"><RefreshRight /></el-icon> | ||||
|  | @ -63,23 +59,25 @@ | |||
|   </div> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| import { PropType } from 'vue' | ||||
| import { formatDate } from '@/utils/formatTime' | ||||
| import MarkdownView from '@/components/MarkdownView/index.vue' | ||||
| import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message' | ||||
| import { useClipboard } from '@vueuse/core' | ||||
| import { PropType } from '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 {useUserStore} from '@/store/modules/user'; | ||||
| import { useUserStore } from '@/store/modules/user' | ||||
| import userAvatarDefaultImg from '@/assets/imgs/avatar.gif' | ||||
| import roleAvatarDefaultImg from '@/assets/ai/gpt.svg' | ||||
| 
 | ||||
| const message = useMessage() // 消息弹窗 | ||||
| const { copy } = useClipboard() // 初始化 copy 到粘贴板 | ||||
| // 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方) | ||||
| const userStore = useUserStore() | ||||
| 
 | ||||
| // 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方) | ||||
| const messageContainer: any = ref(null) | ||||
| const isScrolling = ref(false) //用于判断用户是否在滚动 | ||||
| 
 | ||||
| const userStore = useUserStore() | ||||
| const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg) | ||||
| 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) => { | ||||
|   await nextTick(() => { | ||||
|     // TODO @fan:中文写作习惯,中英文之间要有空格;另外,nextick 哈,idea 如果有绿色波兰线,可以关注下 | ||||
|     //注意要使用nexttick以免获取不到dom | ||||
|     if (isIgnore || !isScrolling.value) { | ||||
|       messageContainer.value.scrollTop = | ||||
|         messageContainer.value.scrollHeight - messageContainer.value.offsetHeight | ||||
|     } | ||||
|   }) | ||||
|   // 注意要使用 nextTick 以免获取不到 dom | ||||
|   await nextTick() | ||||
|   if (isIgnore || !isScrolling.value) { | ||||
|     messageContainer.value.scrollTop = | ||||
|       messageContainer.value.scrollHeight - messageContainer.value.offsetHeight | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 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 scrollContainer = messageContainer.value | ||||
|   scrollContainer.scrollTop = scrollContainer.scrollHeight | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 回到顶部 | ||||
|  */ | ||||
| /** 回到顶部 */ | ||||
| const handlerGoTop = async () => { | ||||
|   const scrollContainer = messageContainer.value | ||||
|   scrollContainer.scrollTop = 0 | ||||
| } | ||||
| 
 | ||||
| // 监听 list | ||||
| // TODO @fan:这个木有,是不是删除啦 | ||||
| const { list, conversationId } = toRefs(props) | ||||
| watch(list, async (newValue, oldValue) => { | ||||
|   console.log('watch list', list) | ||||
| }) | ||||
| defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用 | ||||
| 
 | ||||
| // 提供方法给 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 () => { | ||||
|   messageContainer.value.addEventListener('scroll', handleScroll) | ||||
| }) | ||||
|  | @ -199,15 +173,7 @@ onMounted(async () => { | |||
| <style scoped lang="scss"> | ||||
| .message-container { | ||||
|   position: relative; | ||||
|   //top: 0; | ||||
|   //bottom: 0; | ||||
|   //left: 0; | ||||
|   //right: 0; | ||||
|   //width: 100%; | ||||
|   //height: 100%; | ||||
|   overflow-y: scroll; | ||||
|   //padding: 0 15px; | ||||
|   //z-index: -1; | ||||
| } | ||||
| 
 | ||||
| // 中间 | ||||
|  | @ -231,11 +197,6 @@ onMounted(async () => { | |||
|     justify-content: flex-start; | ||||
|   } | ||||
| 
 | ||||
|   .avatar { | ||||
|     //height: 170px; | ||||
|     //width: 170px; | ||||
|   } | ||||
| 
 | ||||
|   .message { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|  | @ -272,7 +233,6 @@ onMounted(async () => { | |||
|         color: #fff; | ||||
|         display: inline; | ||||
|         background-color: #267fff; | ||||
|         color: #fff; | ||||
|         box-shadow: 0 0 0 1px #267fff; | ||||
|         border-radius: 10px; | ||||
|         padding: 10px; | ||||
|  | @ -1,31 +1,35 @@ | |||
| <!-- 消息列表为空时,展示 prompt 列表 --> | ||||
| <template> | ||||
|   <div class="chat-empty"> | ||||
| 
 | ||||
|     <!--  title  --> | ||||
|     <!-- title --> | ||||
|     <div class="center-container"> | ||||
|       <div class="title">芋艿 AI</div> | ||||
|       <div class="title">芋道 AI</div> | ||||
|       <div class="role-list"> | ||||
|         <div class="role-item" v-for="prompt in promptList" :key="prompt.prompt" @click="handlerPromptClick(prompt)"> | ||||
|           {{prompt.prompt}} | ||||
|         <div | ||||
|           class="role-item" | ||||
|           v-for="prompt in promptList" | ||||
|           :key="prompt.prompt" | ||||
|           @click="handlerPromptClick(prompt)" | ||||
|         > | ||||
|           {{ prompt.prompt }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| 
 | ||||
| const promptList = ref<any[]>() // 角色列表 | ||||
| promptList.value = [ | ||||
| const promptList = [ | ||||
|   { | ||||
|     "prompt": "今天气怎么样?", | ||||
|     prompt: '今天气怎么样?' | ||||
|   }, | ||||
|   { | ||||
|     "prompt": "写一首好听的诗歌?", | ||||
|     prompt: '写一首好听的诗歌?' | ||||
|   } | ||||
| ] | ||||
| ] // prompt 列表 | ||||
| 
 | ||||
| const emits = defineEmits(['onPrompt']) | ||||
| 
 | ||||
| /** 选中 prompt 点击 */ | ||||
| const handlerPromptClick = async ({ prompt }) => { | ||||
|   emits('onPrompt', prompt) | ||||
| } | ||||
|  | @ -1,25 +1,21 @@ | |||
| <!-- message 新增对话 --> | ||||
| <!-- 无聊天对话时,在 message 区域,可以新增对话 --> | ||||
| <template> | ||||
|   <div class="new-chat" > | ||||
|   <div class="new-chat"> | ||||
|     <div class="box-center"> | ||||
|       <div class="tip">点击下方按钮,开始你的对话吧</div> | ||||
|       <div class="btns"><el-button type="primary" round @click="handlerNewChat">新建对话</el-button></div> | ||||
|       <div class="btns"> | ||||
|         <el-button type="primary" round @click="handlerNewChat">新建对话</el-button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| const emits = defineEmits(['onNewConversation']) | ||||
| 
 | ||||
| // 定义钩子 | ||||
| const emits = defineEmits(['onNewChat']) | ||||
| 
 | ||||
| /** | ||||
|  * 新建 chat | ||||
|  */ | ||||
| const handlerNewChat = async () => { | ||||
|   await emits('onNewChat') | ||||
| /** 新建 conversation 聊天对话 */ | ||||
| const handlerNewChat = () => { | ||||
|   emits('onNewConversation') | ||||
| } | ||||
| 
 | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
| .new-chat { | ||||
|  | @ -1,13 +1,20 @@ | |||
| <template> | ||||
|   <div class="category-list"> | ||||
|     <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 plain round size="small" v-else type="primary" @click="handleCategoryClick(category)">{{ category }}</el-button> | ||||
|     <div class="category" v-for="category in categoryList" :key="category"> | ||||
|       <el-button | ||||
|         plain | ||||
|         round | ||||
|         size="small" | ||||
|         :type="category === active ? 'primary' : ''" | ||||
|         @click="handleCategoryClick(category)" | ||||
|       > | ||||
|         {{ category }} | ||||
|       </el-button> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| import {PropType} from "vue"; | ||||
| import { PropType } from 'vue' | ||||
| 
 | ||||
| // 定义属性 | ||||
| defineProps({ | ||||
|  | @ -25,11 +32,10 @@ defineProps({ | |||
| // 定义回调 | ||||
| const emits = defineEmits(['onCategoryClick']) | ||||
| 
 | ||||
| // 处理分类点击事件 | ||||
| const handleCategoryClick = async (category) => { | ||||
| /** 处理分类点击事件 */ | ||||
| const handleCategoryClick = async (category: string) => { | ||||
|   emits('onCategoryClick', category) | ||||
| } | ||||
| 
 | ||||
| </script> | ||||
| <style scoped lang="scss"> | ||||
| .category-list { | ||||
|  | @ -1,32 +1,30 @@ | |||
| <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"> | ||||
|       <el-card class="card" body-class="card-body"> | ||||
|         <!--  更多 --> | ||||
|         <!-- 更多操作 --> | ||||
|         <div class="more-container" v-if="showMore"> | ||||
|           <el-dropdown @command="handleMoreClick"> | ||||
|           <span class="el-dropdown-link"> | ||||
|              <el-button type="text" > | ||||
|             <span class="el-dropdown-link"> | ||||
|               <el-button type="text"> | ||||
|                 <el-icon><More /></el-icon> | ||||
|               </el-button> | ||||
|           </span> | ||||
|             <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 --> | ||||
|             </span> | ||||
|             <template #dropdown> | ||||
|               <el-dropdown-menu> | ||||
|                 <el-dropdown-item :command="['edit', role]" > | ||||
|                   <el-icon><EditPen /></el-icon>编辑 | ||||
|                 <el-dropdown-item :command="['edit', role]"> | ||||
|                   <Icon icon="ep:edit" color="#787878" />编辑 | ||||
|                 </el-dropdown-item> | ||||
|                 <el-dropdown-item :command="['delete', role]"  style="color: red;" > | ||||
|                   <el-icon><Delete /></el-icon> | ||||
|                   <span>删除</span> | ||||
|                 <el-dropdown-item :command="['delete', role]" style="color: red"> | ||||
|                   <Icon icon="ep:delete" color="red" />删除 | ||||
|                 </el-dropdown-item> | ||||
|               </el-dropdown-menu> | ||||
|             </template> | ||||
|           </el-dropdown> | ||||
|         </div> | ||||
|         <!--  头像 --> | ||||
|         <!-- 角色信息 --> | ||||
|         <div> | ||||
|           <img class="avatar" :src="role.avatar"/> | ||||
|           <img class="avatar" :src="role.avatar" /> | ||||
|         </div> | ||||
|         <div class="right-container"> | ||||
|           <div class="content-container"> | ||||
|  | @ -44,8 +42,8 @@ | |||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import {ChatRoleVO} from '@/api/ai/model/chatRole' | ||||
| import {PropType, ref} from "vue"; | ||||
| import {Delete, EditPen, More} from "@element-plus/icons-vue"; | ||||
| import {PropType, ref} from 'vue' | ||||
| import {More} from '@element-plus/icons-vue' | ||||
| 
 | ||||
| const tabsRef = ref<any>() // tabs ref | ||||
| 
 | ||||
|  | @ -65,10 +63,11 @@ const props = defineProps({ | |||
|     default: false | ||||
|   } | ||||
| }) | ||||
| 
 | ||||
| // 定义钩子 | ||||
| const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage']) | ||||
| 
 | ||||
| // more 点击 | ||||
| /** 操作:编辑、删除 */ | ||||
| const handleMoreClick = async (data) => { | ||||
|   const type = data[0] | ||||
|   const role = data[1] | ||||
|  | @ -79,28 +78,20 @@ const handleMoreClick = async (data) => { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| // 使用 | ||||
| /** 选中 */ | ||||
| const handleUseClick = (role) => { | ||||
|   emits('onUse', role) | ||||
| } | ||||
| 
 | ||||
| /** 滚动 */ | ||||
| const handleTabsScroll = async () => { | ||||
|   if (tabsRef.value) { | ||||
|     const { scrollTop, scrollHeight, clientHeight } = tabsRef.value; | ||||
|     console.log('scrollTop', scrollTop) | ||||
|     const { scrollTop, scrollHeight, clientHeight } = tabsRef.value | ||||
|     if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) { | ||||
|       console.log('分页') | ||||
|       // page.value++; | ||||
|       // fetchData(page.value); | ||||
|       await emits('onPage') | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
|   console.log('props', props.roleList) | ||||
| }) | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss"> | ||||
|  | @ -114,11 +105,9 @@ onMounted(() => { | |||
|   flex-direction: row; | ||||
|   justify-content: flex-start; | ||||
|   position: relative; | ||||
| 
 | ||||
| } | ||||
| </style> | ||||
| <style scoped lang="scss"> | ||||
| 
 | ||||
| // 卡片列表 | ||||
| .card-list { | ||||
|   display: flex; | ||||
|  | @ -180,9 +169,6 @@ onMounted(() => { | |||
|         margin-top: 2px; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| </style> | ||||
|  | @ -2,8 +2,8 @@ | |||
| <template> | ||||
|   <el-container class="role-container"> | ||||
|     <ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" /> | ||||
|     <!--  header  --> | ||||
|     <Header title="角色仓库" style="position: relative" /> | ||||
|     <!-- header  --> | ||||
|     <RoleHeader title="角色仓库" class="relative" /> | ||||
|     <!--  main  --> | ||||
|     <el-main class="role-main"> | ||||
|       <div class="search-container"> | ||||
|  | @ -18,20 +18,17 @@ | |||
|           @change="getActiveTabsRole" | ||||
|         /> | ||||
|         <el-button | ||||
|           v-if="activeRole == 'my-role'" | ||||
|           v-if="activeTab == 'my-role'" | ||||
|           type="primary" | ||||
|           @click="handlerAddRole" | ||||
|           style="margin-left: 20px" | ||||
|           class="ml-20px" | ||||
|         > | ||||
|           <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 --> | ||||
|           <el-icon> | ||||
|             <User /> | ||||
|           </el-icon> | ||||
|           <Icon icon="ep:user" style="margin-right: 5px;" /> | ||||
|           添加角色 | ||||
|         </el-button> | ||||
|       </div> | ||||
|       <!-- 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"> | ||||
|           <RoleList | ||||
|             :loading="loading" | ||||
|  | @ -41,7 +38,7 @@ | |||
|             @on-edit="handlerCardEdit" | ||||
|             @on-use="handlerCardUse" | ||||
|             @on-page="handlerCardPage('my')" | ||||
|             style="margin-top: 20px" | ||||
|             class="mt-20px" | ||||
|           /> | ||||
|         </el-tab-pane> | ||||
|         <el-tab-pane label="公共角色" name="public-role"> | ||||
|  | @ -57,7 +54,7 @@ | |||
|             @on-edit="handlerCardEdit" | ||||
|             @on-use="handlerCardUse" | ||||
|             @on-page="handlerCardPage('public')" | ||||
|             style="margin-top: 20px" | ||||
|             class="mt-20px" | ||||
|             loading | ||||
|           /> | ||||
|         </el-tab-pane> | ||||
|  | @ -67,28 +64,31 @@ | |||
| </template> | ||||
| 
 | ||||
| <script setup lang="ts"> | ||||
| import { ref } from 'vue' | ||||
| import Header from '@/views/ai/chat/components/Header.vue' | ||||
| import {ref} from 'vue' | ||||
| import RoleHeader from './RoleHeader.vue' | ||||
| import RoleList from './RoleList.vue' | ||||
| import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue' | ||||
| import RoleCategoryList from './RoleCategoryList.vue' | ||||
| import { ChatRoleApi, ChatRolePageReqVO, ChatRoleVO } from '@/api/ai/model/chatRole' | ||||
| import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' | ||||
| import { TabsPaneContext } from 'element-plus' | ||||
| import { Search, User } from '@element-plus/icons-vue' | ||||
| import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole' | ||||
| import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation' | ||||
| import {Search} from '@element-plus/icons-vue' | ||||
| import {TabsPaneContext} from 'element-plus' | ||||
| 
 | ||||
| const router = useRouter() // 路由对象 | ||||
| 
 | ||||
| // 属性定义 | ||||
| const loading = ref<boolean>(false) // 加载中 | ||||
| const activeRole = ref<string>('my-role') // 选中的角色 TODO @fan:是不是叫 activeTab 会更明确一点哈。选中的角色,会以为是某个角色 | ||||
| const activeTab = ref<string>('my-role') // 选中的角色 Tab | ||||
| const search = ref<string>('') // 加载中 | ||||
| // TODO @fan:要不 myPage、pubPage,搞成类似 const queryParams = reactive({ ,分别搞成两个大的参数哈? | ||||
| const myPageNo = ref<number>(1) // my 分页下标 | ||||
| const myPageSize = ref<number>(50) // my 分页大小 | ||||
| const myRoleParams = reactive({ | ||||
|   pageNo: 1, | ||||
|   pageSize: 50 | ||||
| }) | ||||
| const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小 | ||||
| const publicPageNo = ref<number>(1) // public 分页下标 | ||||
| const publicPageSize = ref<number>(50) // public 分页大小 | ||||
| const publicRoleParams = reactive({ | ||||
|   pageNo: 1, | ||||
|   pageSize: 50 | ||||
| }) | ||||
| const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小 | ||||
| const activeCategory = ref<string>('全部') // 选择中的分类 | ||||
| const categoryList = ref<string[]>([]) // 角色分类类别 | ||||
|  | @ -96,7 +96,7 @@ const categoryList = ref<string[]>([]) // 角色分类类别 | |||
| /** tabs 点击 */ | ||||
| const handleTabsClick = async (tab: TabsPaneContext) => { | ||||
|   // 设置切换状态 | ||||
|   activeRole.value = tab.paneName + '' | ||||
|   activeTab.value = tab.paneName + '' | ||||
|   // 切换的时候重新加载数据 | ||||
|   await getActiveTabsRole() | ||||
| } | ||||
|  | @ -104,12 +104,11 @@ const handleTabsClick = async (tab: TabsPaneContext) => { | |||
| /** 获取 my role 我的角色 */ | ||||
| const getMyRole = async (append?: boolean) => { | ||||
|   const params: ChatRolePageReqVO = { | ||||
|     pageNo: myPageNo.value, | ||||
|     pageSize: myPageSize.value, | ||||
|     ...myRoleParams, | ||||
|     name: search.value, | ||||
|     publicStatus: false | ||||
|   } | ||||
|   const { total, list } = await ChatRoleApi.getMyPage(params) | ||||
|   const { list } = await ChatRoleApi.getMyPage(params) | ||||
|   if (append) { | ||||
|     myRoleList.value.push.apply(myRoleList.value, list) | ||||
|   } else { | ||||
|  | @ -120,8 +119,7 @@ const getMyRole = async (append?: boolean) => { | |||
| /** 获取 public role 公共角色 */ | ||||
| const getPublicRole = async (append?: boolean) => { | ||||
|   const params: ChatRolePageReqVO = { | ||||
|     pageNo: publicPageNo.value, | ||||
|     pageSize: publicPageSize.value, | ||||
|     ...publicRoleParams, | ||||
|     category: activeCategory.value === '全部' ? '' : activeCategory.value, | ||||
|     name: search.value, | ||||
|     publicStatus: true | ||||
|  | @ -136,20 +134,18 @@ const getPublicRole = async (append?: boolean) => { | |||
| 
 | ||||
| /** 获取选中的 tabs 角色 */ | ||||
| const getActiveTabsRole = async () => { | ||||
|   if (activeRole.value === 'my-role') { | ||||
|     myPageNo.value = 1 | ||||
|   if (activeTab.value === 'my-role') { | ||||
|     myRoleParams.pageNo = 1 | ||||
|     await getMyRole() | ||||
|   } else { | ||||
|     publicPageNo.value = 1 | ||||
|     publicRoleParams.pageNo = 1 | ||||
|     await getPublicRole() | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** 获取角色分类列表 */ | ||||
| const getRoleCategoryList = async () => { | ||||
|   const res = await ChatRoleApi.getCategoryList() | ||||
|   const defaultRole = ['全部'] | ||||
|   categoryList.value = [...defaultRole, ...res] | ||||
|   categoryList.value = ['全部', ...(await ChatRoleApi.getCategoryList())] | ||||
| } | ||||
| 
 | ||||
| /** 处理分类点击 */ | ||||
|  | @ -165,6 +161,10 @@ const formRef = ref() | |||
| const handlerAddRole = async () => { | ||||
|   formRef.value.open('my-create', null, '添加角色') | ||||
| } | ||||
| /** 编辑角色 */ | ||||
| const handlerCardEdit = async (role) => { | ||||
|   formRef.value.open('my-update', role.id, '编辑角色') | ||||
| } | ||||
| 
 | ||||
| /** 添加角色成功 */ | ||||
| const handlerAddRoleSuccess = async (e) => { | ||||
|  | @ -172,28 +172,22 @@ const handlerAddRoleSuccess = async (e) => { | |||
|   await getActiveTabsRole() | ||||
| } | ||||
| 
 | ||||
| // card 删除 | ||||
| /** 删除角色 */ | ||||
| const handlerCardDelete = async (role) => { | ||||
|   await ChatRoleApi.deleteMy(role.id) | ||||
|   // 刷新数据 | ||||
|   await getActiveTabsRole() | ||||
| } | ||||
| 
 | ||||
| // card 编辑 | ||||
| const handlerCardEdit = async (role) => { | ||||
|   formRef.value.open('my-update', role.id, '编辑角色') | ||||
| } | ||||
| 
 | ||||
| /** card 分页:获取下一页 */ | ||||
| /** 角色分页:获取下一页 */ | ||||
| const handlerCardPage = async (type) => { | ||||
|   console.log('handlerCardPage', type) | ||||
|   try { | ||||
|     loading.value = true | ||||
|     if (type === 'public') { | ||||
|       publicPageNo.value++ | ||||
|       publicRoleParams.pageNo++ | ||||
|       await getPublicRole(true) | ||||
|     } else { | ||||
|       myPageNo.value++ | ||||
|       myRoleParams.pageNo++ | ||||
|       await getMyRole(true) | ||||
|     } | ||||
|   } finally { | ||||
|  | @ -208,10 +202,10 @@ const handlerCardUse = async (role) => { | |||
|     roleId: role.id | ||||
|   } as unknown as ChatConversationVO | ||||
|   const conversationId = await ChatConversationApi.createChatConversationMy(data) | ||||
| 
 | ||||
|   // 2. 跳转页面 | ||||
|   // TODO @fan:最好用 name,后续可能会改~~~ | ||||
|   await router.push({ | ||||
|     path: `/ai/chat`, | ||||
|     name: 'AiChat', | ||||
|     query: { | ||||
|       conversationId: conversationId | ||||
|     } | ||||
|  | @ -225,15 +219,14 @@ onMounted(async () => { | |||
|   // 获取 role 数据 | ||||
|   await getActiveTabsRole() | ||||
| }) | ||||
| // TODO @fan:css 是不是可以融合到 scss 里面呀? | ||||
| </script> | ||||
| <style lang="css"> | ||||
| <!-- 覆盖 element ui css --> | ||||
| <style lang="scss"> | ||||
| .el-tabs__content { | ||||
|   position: relative; | ||||
|   height: 100%; | ||||
|   overflow: hidden; | ||||
| } | ||||
| 
 | ||||
| .el-tabs__nav-scroll { | ||||
|   margin: 10px 20px; | ||||
| } | ||||
|  | @ -1,33 +1,35 @@ | |||
| <template> | ||||
|   <el-container class="ai-layout"> | ||||
|     <!-- 左侧:对话列表 --> | ||||
|     <Conversation | ||||
|     <ConversationList | ||||
|       :active-id="activeConversationId" | ||||
|       ref="conversationRef" | ||||
|       @onConversationCreate="handleConversationCreate" | ||||
|       @onConversationClick="handleConversationClick" | ||||
|       @onConversationClear="handlerConversationClear" | ||||
|       @onConversationDelete="handlerConversationDelete" | ||||
|       ref="conversationListRef" | ||||
|       @on-conversation-create="handleConversationCreateSuccess" | ||||
|       @on-conversation-click="handleConversationClick" | ||||
|       @on-conversation-clear="handleConversationClear" | ||||
|       @on-conversation-delete="handlerConversationDelete" | ||||
|     /> | ||||
|     <!-- 右侧:对话详情 --> | ||||
|     <el-container class="detail-container"> | ||||
|       <el-header class="header"> | ||||
|         <div class="title"> | ||||
|           {{ activeConversation?.title ? activeConversation?.title : '对话' }} | ||||
|           <span v-if="list.length">({{ list.length }})</span> | ||||
|           <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span> | ||||
|         </div> | ||||
|         <div class="btns" v-if="activeConversation"> | ||||
|           <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm"> | ||||
|             <span v-html="activeConversation?.modelName"></span> | ||||
|             <Icon icon="ep:setting" style="margin-left: 10px" /> | ||||
|             <Icon icon="ep:setting" class="ml-10px" /> | ||||
|           </el-button> | ||||
|           <el-button size="small" class="btn" @click="handlerMessageClear"> | ||||
|             <!-- TODO @fan:style 部分,可以考虑用 unocss 替代 --> | ||||
|             <img src="@/assets/ai/clear.svg" style="height: 14px" /> | ||||
|             <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" /> | ||||
|           </el-button> | ||||
|           <el-button size="small" class="btn"> | ||||
|             <Icon icon="ep:download" color="#787878" /> | ||||
|           </el-button> | ||||
|           <el-button size="small" class="btn" @click="handleGoTopMessage"> | ||||
|             <Icon icon="ep:top" color="#787878" /> | ||||
|           </el-button> | ||||
|           <!-- 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> | ||||
|       </el-header> | ||||
| 
 | ||||
|  | @ -35,20 +37,27 @@ | |||
|       <el-main class="main-container"> | ||||
|         <div> | ||||
|           <div class="message-container"> | ||||
|             <MessageLoading v-if="listLoading" /> | ||||
|             <MessageNewChat v-if="!activeConversation" @on-new-chat="handlerNewChat" /> | ||||
|             <ChatEmpty | ||||
|               v-if="!listLoading && messageList.length === 0 && activeConversation" | ||||
|               @on-prompt="doSend" | ||||
|             <!-- 情况一:消息加载中 --> | ||||
|             <MessageLoading v-if="activeMessageListLoading" /> | ||||
|             <!-- 情况二:无聊天对话时 --> | ||||
|             <MessageNewConversation | ||||
|               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" | ||||
|               :conversation="activeConversation" | ||||
|               :list="messageList" | ||||
|               @on-delete-success="handlerMessageDelete" | ||||
|               @on-edit="handlerMessageEdit" | ||||
|               @on-refresh="handlerMessageRefresh" | ||||
|               @on-delete-success="handleMessageDelete" | ||||
|               @on-edit="handleMessageEdit" | ||||
|               @on-refresh="handleMessageRefresh" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | @ -60,8 +69,8 @@ | |||
|           <textarea | ||||
|             class="prompt-input" | ||||
|             v-model="prompt" | ||||
|             @keydown="onSend" | ||||
|             @input="onPromptInput" | ||||
|             @keydown="handleSendByKeydown" | ||||
|             @input="handlePromptInput" | ||||
|             @compositionstart="onCompositionstart" | ||||
|             @compositionend="onCompositionend" | ||||
|             placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)" | ||||
|  | @ -69,12 +78,12 @@ | |||
|           <div class="prompt-btns"> | ||||
|             <div> | ||||
|               <el-switch v-model="enableContext" /> | ||||
|               <span style="font-size: 14px; color: #8f8f8f">上下文</span> | ||||
|               <span class="ml-5px text-14px text-#8f8f8f">上下文</span> | ||||
|             </div> | ||||
|             <el-button | ||||
|               type="primary" | ||||
|               size="default" | ||||
|               @click="onSendBtn" | ||||
|               @click="handleSendByButton" | ||||
|               :loading="conversationInProgress" | ||||
|               v-if="conversationInProgress == false" | ||||
|             > | ||||
|  | @ -93,148 +102,251 @@ | |||
|       </el-footer> | ||||
|     </el-container> | ||||
| 
 | ||||
|     <!--  ========= 额外组件 ==========  --> | ||||
|     <!-- 更新对话 Form --> | ||||
|     <ChatConversationUpdateForm | ||||
|       ref="chatConversationUpdateFormRef" | ||||
|       @success="handlerTitleSuccess" | ||||
|     <ConversationUpdateForm | ||||
|       ref="conversationUpdateFormRef" | ||||
|       @success="handleConversationUpdateSuccess" | ||||
|     /> | ||||
|   </el-container> | ||||
| </template> | ||||
| 
 | ||||
| <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 { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' | ||||
| import ChatConversationUpdateForm from '@/views/ai/chat/components/ChatConversationUpdateForm.vue' | ||||
| import { Download, Top } from '@element-plus/icons-vue' | ||||
| import ConversationList from './components/conversation/ConversationList.vue' | ||||
| import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue' | ||||
| import MessageList from './components/message/MessageList.vue' | ||||
| import MessageListEmpty from './components/message/MessageListEmpty.vue' | ||||
| import MessageLoading from './components/message/MessageLoading.vue' | ||||
| import MessageNewConversation from './components/message/MessageNewConversation.vue' | ||||
| 
 | ||||
| /** AI 聊天对话 列表 */ | ||||
| defineOptions({ name: 'AiChat' }) | ||||
| 
 | ||||
| const route = useRoute() // 路由 | ||||
| const message = useMessage() // 消息弹窗 | ||||
| 
 | ||||
| // ref 属性定义 | ||||
| const activeConversationId = ref<string | null>(null) // 选中的对话编号 | ||||
| // 聊天对话 | ||||
| const conversationListRef = ref() | ||||
| const activeConversationId = ref<number | null>(null) // 选中的对话编号 | ||||
| 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 inputTimeout = ref<any>() // 处理输入中回车的定时器 | ||||
| const prompt = ref<string>() // prompt | ||||
| 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 list = ref<ChatMessageVO[]>([]) // 列表的数据 | ||||
| const listLoading = ref<boolean>(false) // 是否加载中 | ||||
| const listLoadingTime = ref<any>() // time 定时器,如果加载速度很快,就不进入加载中 | ||||
| /** 获取对话信息 */ | ||||
| const getConversation = async (id: number | null) => { | ||||
|   if (!id) { | ||||
|     return | ||||
|   } | ||||
|   const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id) | ||||
|   if (!conversation) { | ||||
|     return | ||||
|   } | ||||
|   activeConversation.value = conversation | ||||
|   activeConversationId.value = conversation.id | ||||
| } | ||||
| 
 | ||||
| // 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方) | ||||
| 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 头像 | ||||
| const defaultRoleAvatar = | ||||
|   'http://test.yudao.iocoder.cn/eaef5f41acb911dd718429a0702dcc3c61160d16e57ba1d543132fab58934f9f.png' | ||||
|   // 更新选中的对话 id | ||||
|   activeConversationId.value = conversation.id | ||||
|   activeConversation.value = conversation | ||||
|   // 刷新 message 列表 | ||||
|   await getMessageList() | ||||
|   // 滚动底部 | ||||
|   scrollToBottom(true) | ||||
|   // 清空输入框 | ||||
|   prompt.value = '' | ||||
|   return true | ||||
| } | ||||
| 
 | ||||
| // =========== 自提滚动效果 | ||||
| /** 删除某个对话*/ | ||||
| const handlerConversationDelete = async (delConversation: ChatConversationVO) => { | ||||
|   // 删除的对话如果是当前选中的,那么就重置 | ||||
|   if (activeConversationId.value === delConversation.id) { | ||||
|     await handleConversationClear() | ||||
|   } | ||||
| } | ||||
| /** 清空选中的对话 */ | ||||
| const handleConversationClear = async () => { | ||||
|   // 对话进行中,不允许切换 | ||||
|   if (conversationInProgress.value) { | ||||
|     message.alert('对话中,不允许切换!') | ||||
|     return false | ||||
|   } | ||||
|   activeConversationId.value = null | ||||
|   activeConversation.value = null | ||||
|   activeMessageList.value = [] | ||||
| } | ||||
| 
 | ||||
| // TODO @fan:这个方法,要不加个方法注释 | ||||
| const textRoll = async () => { | ||||
|   let index = 0 | ||||
| /** 修改聊天对话 */ | ||||
| const conversationUpdateFormRef = ref() | ||||
| const openChatConversationUpdateForm = async () => { | ||||
|   conversationUpdateFormRef.value.open(activeConversationId.value) | ||||
| } | ||||
| const handleConversationUpdateSuccess = async () => { | ||||
|   // 对话更新成功,刷新最新信息 | ||||
|   await getConversation(activeConversationId.value) | ||||
| } | ||||
| 
 | ||||
| /** 处理聊天对话的创建成功 */ | ||||
| const handleConversationCreate = async () => { | ||||
|   // 创建对话 | ||||
|   await conversationListRef.value.createConversation() | ||||
| } | ||||
| /** 处理聊天对话的创建成功 */ | ||||
| const handleConversationCreateSuccess = async () => { | ||||
|   // 创建新的对话,清空输入框 | ||||
|   prompt.value = '' | ||||
| } | ||||
| 
 | ||||
| // =========== 【消息列表】相关 =========== | ||||
| 
 | ||||
| /** 获取消息 message 列表 */ | ||||
| const getMessageList = async () => { | ||||
|   try { | ||||
|     // 只能执行一次 | ||||
|     if (textRoleRunning.value) { | ||||
|     if (activeConversationId.value === null) { | ||||
|       return | ||||
|     } | ||||
|     // 设置状态 | ||||
|     textRoleRunning.value = true | ||||
|     displayedText.value = '' | ||||
|     const task = async () => { | ||||
|       // 调整速度 | ||||
|       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 | ||||
|       } | ||||
|     // Timer 定时器,如果加载速度很快,就不进入加载中 | ||||
|     activeMessageListLoadingTimer.value = setTimeout(() => { | ||||
|       activeMessageListLoading.value = true | ||||
|     }, 60) | ||||
| 
 | ||||
|       // 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] | ||||
|         index++ | ||||
| 
 | ||||
|         // 更新 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) | ||||
|     // 滚动到最下面 | ||||
|     await nextTick() | ||||
|     await scrollToBottom() | ||||
|   } finally { | ||||
|     // time 定时器,如果加载速度很快,就不进入加载中 | ||||
|     if (activeMessageListLoadingTimer.value) { | ||||
|       clearTimeout(activeMessageListLoadingTimer.value) | ||||
|     } | ||||
|     // 加载结束 | ||||
|     activeMessageListLoading.value = false | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // ============ 处理对话滚动 ============== | ||||
| /** | ||||
|  * 消息列表 | ||||
|  * | ||||
|  * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去 | ||||
|  */ | ||||
| const messageList = computed(() => { | ||||
|   if (activeMessageList.value.length > 0) { | ||||
|     return activeMessageList.value | ||||
|   } | ||||
|   // 没有消息时,如果有 systemMessage 则展示它 | ||||
|   if (activeConversation.value?.systemMessage) { | ||||
|     return [ | ||||
|       { | ||||
|         id: 0, | ||||
|         type: 'system', | ||||
|         content: activeConversation.value.systemMessage | ||||
|       } | ||||
|     ] | ||||
|   } | ||||
|   return [] | ||||
| }) | ||||
| 
 | ||||
| function scrollToBottom(isIgnore?: boolean) { | ||||
|   // isIgnore = isIgnore !== null ? isIgnore : false | ||||
|   nextTick(() => { | ||||
|     if (messageRef.value) { | ||||
|       messageRef.value.scrollToBottom(isIgnore) | ||||
| /** 处理删除 message 消息 */ | ||||
| const handleMessageDelete = () => { | ||||
|   if (conversationInProgress.value) { | ||||
|     message.alert('回答中,不能删除!') | ||||
|     return | ||||
|   } | ||||
|   // 刷新 message 列表 | ||||
|   getMessageList() | ||||
| } | ||||
| 
 | ||||
| /** 处理 message 清空 */ | ||||
| const handlerMessageClear = async () => { | ||||
|   if (!activeConversationId.value) { | ||||
|     return | ||||
|   } | ||||
|   try { | ||||
|     // 确认提示 | ||||
|     await message.delConfirm('确认清空对话消息?') | ||||
|     // 清空对话 | ||||
|     await ChatMessageApi.deleteByConversationId(activeConversationId.value) | ||||
|     // 刷新 message 列表 | ||||
|     activeMessageList.value = [] | ||||
|   } catch {} | ||||
| } | ||||
| 
 | ||||
| /** 回到 message 列表的顶部 */ | ||||
| const handleGoTopMessage = () => { | ||||
|   messageRef.value.handlerGoTop() | ||||
| } | ||||
| 
 | ||||
| // =========== 【发送消息】相关 =========== | ||||
| 
 | ||||
| /** 处理来自 keydown 的发送消息 */ | ||||
| const handleSendByKeydown = async (event) => { | ||||
|   // 判断用户是否在输入 | ||||
|   if (isComposing.value) { | ||||
|     return | ||||
|   } | ||||
|   // 进行中不允许发送 | ||||
|   if (conversationInProgress.value) { | ||||
|     return | ||||
|   } | ||||
|   const content = prompt.value?.trim() as string | ||||
|   if (event.key === 'Enter') { | ||||
|     if (event.shiftKey) { | ||||
|       // 插入换行 | ||||
|       prompt.value += '\r\n' | ||||
|       event.preventDefault() // 防止默认的换行行为 | ||||
|     } else { | ||||
|       // 发送消息 | ||||
|       await doSendMessage(content) | ||||
|       event.preventDefault() // 防止默认的提交行为 | ||||
|     } | ||||
|   }) | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // ============= 处理聊天输入回车发送 ============= | ||||
| 
 | ||||
| // TODO @fan:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 | ||||
| const onCompositionstart = () => { | ||||
|   isComposing.value = true | ||||
| /** 处理来自【发送】按钮的发送消息 */ | ||||
| const handleSendByButton = () => { | ||||
|   doSendMessage(prompt.value?.trim() as string) | ||||
| } | ||||
| 
 | ||||
| const onCompositionend = () => { | ||||
|   // console.log('输入结束...') | ||||
|   setTimeout(() => { | ||||
|     isComposing.value = false | ||||
|   }, 200) | ||||
| } | ||||
| 
 | ||||
| const onPromptInput = (event) => { | ||||
| /** 处理 prompt 输入变化 */ | ||||
| const handlePromptInput = (event) => { | ||||
|   // 非输入法 输入设置为 true | ||||
|   if (!isComposing.value) { | ||||
|     // 回车 event data 是 null | ||||
|  | @ -252,106 +364,76 @@ const onPromptInput = (event) => { | |||
|     isComposing.value = false | ||||
|   }, 400) | ||||
| } | ||||
| 
 | ||||
| // ============== 对话消息相关 ================= | ||||
| 
 | ||||
| /** | ||||
|  * 发送消息 | ||||
|  */ | ||||
| const onSend = 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 doSend(content) | ||||
|       event.preventDefault() // 防止默认的提交行为 | ||||
|     } | ||||
|   } | ||||
| // TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 | ||||
| const onCompositionstart = () => { | ||||
|   isComposing.value = true | ||||
| } | ||||
| const onCompositionend = () => { | ||||
|   // console.log('输入结束...') | ||||
|   setTimeout(() => { | ||||
|     isComposing.value = false | ||||
|   }, 200) | ||||
| } | ||||
| 
 | ||||
| const onSendBtn = async () => { | ||||
|   await doSend(prompt.value?.trim() as string) | ||||
| } | ||||
| 
 | ||||
| const doSend = async (content: string) => { | ||||
|   if (content.length < 2) { | ||||
|     // TODO @fan:这个 message.error(`上传文件大小不能超过${props.fileSize}MB!`) 可以替代,这种形式 | ||||
|     ElMessage({ | ||||
|       message: '请输入内容!', | ||||
|       type: 'error' | ||||
|     }) | ||||
| /** 真正执行【发送】消息操作 */ | ||||
| const doSendMessage = async (content: string) => { | ||||
|   // 校验 | ||||
|   if (content.length < 1) { | ||||
|     message.error('发送失败,原因:内容为空!') | ||||
|     return | ||||
|   } | ||||
|   // TODO @fan:这个 message.error(`上传文件大小不能超过${props.fileSize}MB!`) 可以替代,这种形式 | ||||
|   if (activeConversationId.value == null) { | ||||
|     ElMessage({ | ||||
|       message: '还没创建对话,不能发送!', | ||||
|       type: 'error' | ||||
|     }) | ||||
|     message.error('还没创建对话,不能发送!') | ||||
|     return | ||||
|   } | ||||
|   // 清空输入框 | ||||
|   prompt.value = '' | ||||
|   // TODO @fan:idea 这里会报类型错误,是不是可以解决下哈 | ||||
|   const userMessage = { | ||||
|   // 执行发送 | ||||
|   await doSendMessageStream({ | ||||
|     conversationId: activeConversationId.value, | ||||
|     content: content | ||||
|   } as ChatMessageVO | ||||
|   // stream | ||||
|   await doSendStream(userMessage) | ||||
|   } as ChatMessageVO) | ||||
| } | ||||
| 
 | ||||
| const doSendStream = async (userMessage: ChatMessageVO) => { | ||||
|   // 创建AbortController实例,以便中止请求 | ||||
| /** 真正执行【发送】消息操作 */ | ||||
| const doSendMessageStream = async (userMessage: ChatMessageVO) => { | ||||
|   // 创建 AbortController 实例,以便中止请求 | ||||
|   conversationInAbortController.value = new AbortController() | ||||
|   // 标记对话进行中 | ||||
|   conversationInProgress.value = true | ||||
|   // 设置为空 | ||||
|   fullText.value = '' | ||||
|   receiveMessageFullText.value = '' | ||||
| 
 | ||||
|   try { | ||||
|     // 先添加两个假数据,等 stream 返回再替换 | ||||
|     list.value.push({ | ||||
|     // 1.1 先添加两个假数据,等 stream 返回再替换 | ||||
|     activeMessageList.value.push({ | ||||
|       id: -1, | ||||
|       conversationId: activeConversationId.value, | ||||
|       type: 'user', | ||||
|       content: userMessage.content, | ||||
|       createTime: new Date() | ||||
|     } as ChatMessageVO) | ||||
|     list.value.push({ | ||||
|     activeMessageList.value.push({ | ||||
|       id: -2, | ||||
|       conversationId: activeConversationId.value, | ||||
|       type: 'system', | ||||
|       type: 'assistant', | ||||
|       content: '思考中...', | ||||
|       createTime: new Date() | ||||
|     } as ChatMessageVO) | ||||
|     // 滚动到最下面 | ||||
|     // TODO @fan:可以 await nextTick();然后同步调用 scrollToBottom() | ||||
|     nextTick(async () => { | ||||
|       await scrollToBottom() | ||||
|     }) | ||||
|     // 开始滚动 | ||||
|     // 1.2 滚动到最下面 | ||||
|     await nextTick() | ||||
|     await scrollToBottom() // 底部 | ||||
|     // 1.3 开始滚动 | ||||
|     textRoll() | ||||
|     // 发送 event stream | ||||
|     let isFirstMessage = true // TODO @fan:isFirstChunk 会更精准 | ||||
|     ChatMessageApi.sendStream( | ||||
|       userMessage.conversationId, // TODO 芋艿:这里可能要在优化; | ||||
| 
 | ||||
|     // 2. 发送 event stream | ||||
|     let isFirstChunk = true // 是否是第一个 chunk 消息段 | ||||
|     await ChatMessageApi.sendChatMessageStream( | ||||
|       userMessage.conversationId, | ||||
|       userMessage.content, | ||||
|       conversationInAbortController.value, | ||||
|       enableContext.value, | ||||
|       async (res) => { | ||||
|         console.log('res', res) | ||||
|         const { code, data, msg } = JSON.parse(res.data) | ||||
|         if (code !== 0) { | ||||
|           message.alert(`对话异常! ${msg}`) | ||||
|  | @ -363,42 +445,33 @@ const doSendStream = async (userMessage: ChatMessageVO) => { | |||
|           return | ||||
|         } | ||||
|         // 首次返回需要添加一个 message 到页面,后面的都是更新 | ||||
|         if (isFirstMessage) { | ||||
|           isFirstMessage = false | ||||
|           // 弹出两个 假数据 | ||||
|           list.value.pop() | ||||
|           list.value.pop() | ||||
|         if (isFirstChunk) { | ||||
|           isFirstChunk = false | ||||
|           // 弹出两个假数据 | ||||
|           activeMessageList.value.pop() | ||||
|           activeMessageList.value.pop() | ||||
|           // 更新返回的数据 | ||||
|           list.value.push(data.send) | ||||
|           list.value.push(data.receive) | ||||
|           activeMessageList.value.push(data.send) | ||||
|           activeMessageList.value.push(data.receive) | ||||
|         } | ||||
|         // debugger | ||||
|         fullText.value = fullText.value + data.receive.content | ||||
|         receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content | ||||
|         // 滚动到最下面 | ||||
|         await scrollToBottom() | ||||
|       }, | ||||
|       (error) => { | ||||
|         message.alert(`对话异常! ${error}`) | ||||
|         // TODO @fan:是不是可以复用 stopStream 方法 | ||||
|         // 标记对话结束 | ||||
|         conversationInProgress.value = false | ||||
|         // 结束 stream 对话 | ||||
|         conversationInAbortController.value.abort() | ||||
|         stopStream() | ||||
|       }, | ||||
|       () => { | ||||
|         // TODO @fan:是不是可以复用 stopStream 方法 | ||||
|         // 标记对话结束 | ||||
|         conversationInProgress.value = false | ||||
|         // 结束 stream 对话 | ||||
|         conversationInAbortController.value.abort() | ||||
|         stopStream() | ||||
|       } | ||||
|     ) | ||||
|   } finally { | ||||
|   } | ||||
|   } catch {} | ||||
| } | ||||
| 
 | ||||
| /** 停止 stream 流式调用 */ | ||||
| const stopStream = async () => { | ||||
|   console.log('stopStream...') | ||||
|   // tip:如果 stream 进行中的 message,就需要调用 controller 结束 | ||||
|   if (conversationInAbortController.value) { | ||||
|     conversationInAbortController.value.abort() | ||||
|  | @ -407,217 +480,98 @@ const stopStream = async () => { | |||
|   conversationInProgress.value = false | ||||
| } | ||||
| 
 | ||||
| // ============== message 数据 ================= | ||||
| 
 | ||||
| /** 消息列表 */ | ||||
| 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) => { | ||||
| /** 编辑 message:设置为 prompt,可以再次编辑 */ | ||||
| const handleMessageEdit = (message: ChatMessageVO) => { | ||||
|   prompt.value = message.content | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 刷新 message:基于指定消息,再次发起对话 | ||||
|  */ | ||||
| const handlerMessageRefresh = async (message: ChatMessageVO) => { | ||||
|   await doSend(message.content) | ||||
| /** 刷新 message:基于指定消息,再次发起对话 */ | ||||
| const handleMessageRefresh = (message: ChatMessageVO) => { | ||||
|   doSendMessage(message.content) | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 回到顶部 | ||||
|  */ | ||||
| const handlerGoTop = async () => { | ||||
|   await messageRef.value.handlerGoTop() | ||||
| } | ||||
| // ============== 【消息滚动】相关 ============= | ||||
| 
 | ||||
| /** | ||||
|  * message 清除 | ||||
|  */ | ||||
| const handlerMessageClear = async () => { | ||||
|   if (!activeConversationId.value) { | ||||
|     return | ||||
| /** 滚动到 message 底部 */ | ||||
| const scrollToBottom = async (isIgnore?: boolean) => { | ||||
|   await nextTick() | ||||
|   if (messageRef.value) { | ||||
|     messageRef.value.scrollToBottom(isIgnore) | ||||
|   } | ||||
|   // TODO @fan:需要 try catch 下,不然点击取消会报异常 | ||||
|   // 确认提示 | ||||
|   await message.delConfirm('确认清空对话消息?') | ||||
|   // 清空对话 | ||||
|   await ChatMessageApi.deleteByConversationId(activeConversationId.value as string) | ||||
|   // TODO @fan:是不是直接置空就好啦; | ||||
|   // 刷新 message 列表 | ||||
|   await getMessageList() | ||||
| } | ||||
| 
 | ||||
| /** 自提滚动效果 */ | ||||
| const textRoll = async () => { | ||||
|   let index = 0 | ||||
|   try { | ||||
|     // 只能执行一次 | ||||
|     if (textRoleRunning.value) { | ||||
|       return | ||||
|     } | ||||
|     // 设置状态 | ||||
|     textRoleRunning.value = true | ||||
|     receiveMessageDisplayedText.value = '' | ||||
|     const task = async () => { | ||||
|       // 调整速度 | ||||
|       const diff = | ||||
|         (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10 | ||||
|       if (diff > 5) { | ||||
|         textSpeed.value = 10 | ||||
|       } else if (diff > 2) { | ||||
|         textSpeed.value = 30 | ||||
|       } else if (diff > 1.5) { | ||||
|         textSpeed.value = 50 | ||||
|       } else { | ||||
|         textSpeed.value = 100 | ||||
|       } | ||||
|       // 对话结束,就按 30 的速度 | ||||
|       if (!conversationInProgress.value) { | ||||
|         textSpeed.value = 10 | ||||
|       } | ||||
| 
 | ||||
|       if (index < receiveMessageFullText.value.length) { | ||||
|         receiveMessageDisplayedText.value += receiveMessageFullText.value[index] | ||||
|         index++ | ||||
| 
 | ||||
|         // 更新 message | ||||
|         const lastMessage = activeMessageList.value[activeMessageList.value.length - 1] | ||||
|         lastMessage.content = receiveMessageDisplayedText.value | ||||
|         // 滚动到住下面 | ||||
|         await scrollToBottom() | ||||
|         // 重新设置任务 | ||||
|         timer = setTimeout(task, textSpeed.value) | ||||
|       } else { | ||||
|         // 不是对话中可以结束 | ||||
|         if (!conversationInProgress.value) { | ||||
|           textRoleRunning.value = false | ||||
|           clearTimeout(timer) | ||||
|         } else { | ||||
|           // 重新设置任务 | ||||
|           timer = setTimeout(task, textSpeed.value) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     let timer = setTimeout(task, textSpeed.value) | ||||
|   } catch {} | ||||
| } | ||||
| 
 | ||||
| /** 初始化 **/ | ||||
| onMounted(async () => { | ||||
|   // 设置当前对话 TODO 角色仓库过来的,自带 conversationId 需要选中 | ||||
|   // 如果有 conversationId 参数,则默认选中 | ||||
|   if (route.query.conversationId) { | ||||
|     const id = route.query.conversationId as string | ||||
|     const id = route.query.conversationId as unknown as number | ||||
|     activeConversationId.value = id | ||||
|     await getConversation(id) | ||||
|   } | ||||
| 
 | ||||
|   // 获取列表数据 | ||||
|   listLoading.value = true | ||||
|   activeMessageListLoading.value = true | ||||
|   await getMessageList() | ||||
| }) | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .ai-layout { | ||||
|   // TODO @范 这里height不能 100% 先这样临时处理 | ||||
|   position: absolute; | ||||
|   flex: 1; | ||||
|   top: 0; | ||||
|  | @ -631,8 +585,7 @@ onMounted(async () => { | |||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: space-between; | ||||
|   padding: 0 10px; | ||||
|   padding-top: 10px; | ||||
|   padding: 10px 10px 0; | ||||
| 
 | ||||
|   .btn-new-conversation { | ||||
|     padding: 18px 0; | ||||
|  | @ -771,8 +724,6 @@ onMounted(async () => { | |||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     //width: 100%; | ||||
|     //height: 100%; | ||||
|     overflow-y: hidden; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|  | @ -803,8 +754,7 @@ onMounted(async () => { | |||
|     border: none; | ||||
|     box-sizing: border-box; | ||||
|     resize: none; | ||||
|     padding: 0px 2px; | ||||
|     //padding: 5px 5px; | ||||
|     padding: 0 2px; | ||||
|     overflow: auto; | ||||
|   } | ||||
| 
 | ||||
|  | @ -815,7 +765,7 @@ onMounted(async () => { | |||
|   .prompt-btns { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     padding-bottom: 0px; | ||||
|     padding-bottom: 0; | ||||
|     padding-top: 5px; | ||||
|   } | ||||
| } | ||||
|  | @ -16,5 +16,5 @@ import ChatConversationList from './ChatConversationList.vue' | |||
| import ChatMessageList from './ChatMessageList.vue' | ||||
| 
 | ||||
| /** AI 聊天对话 列表 */ | ||||
| defineOptions({ name: 'ChatConversation' }) | ||||
| defineOptions({ name: 'AiChatManager' }) | ||||
| </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"> | ||||
|         <UploadImg v-model="formData.avatar" height="60px" width="60px" /> | ||||
|       </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-option | ||||
|             v-for="chatModel in chatModelList" | ||||
|  | @ -23,7 +23,7 @@ | |||
|           /> | ||||
|         </el-select> | ||||
|       </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-form-item> | ||||
|       <el-form-item label="角色描述" prop="description"> | ||||
|  | @ -32,7 +32,7 @@ | |||
|       <el-form-item label="角色设定" prop="systemMessage"> | ||||
|         <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" /> | ||||
|       </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 | ||||
|             v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" | ||||
|  | @ -43,10 +43,10 @@ | |||
|           </el-radio> | ||||
|         </el-radio-group> | ||||
|       </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-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 | ||||
|             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 { CommonStatusEnum } from '@/utils/constants' | ||||
| import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel' | ||||
| import {FormRules} from "element-plus"; | ||||
| 
 | ||||
| /** AI 聊天角色 表单 */ | ||||
| defineOptions({ name: 'ChatRoleForm' }) | ||||
|  | @ -92,30 +93,23 @@ const formData = ref({ | |||
|   publicStatus: true, | ||||
|   status: CommonStatusEnum.ENABLE | ||||
| }) | ||||
| const formRules = ref() // reactive(formRulesObj) | ||||
| const formRef = ref() // 表单 Ref | ||||
| const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表 | ||||
| 
 | ||||
| /** 是否【我】自己创建,私有角色 */ | ||||
| // TODO @fan:建议改成计算函数 computed | ||||
| const isUser = (type: string) => { | ||||
|   return type === 'my-create' || type === 'my-update' | ||||
| } | ||||
| const isUser = computed(() => { | ||||
|   return formType.value === 'my-create' || formType.value === 'my-update' | ||||
| }) | ||||
| 
 | ||||
| // TODO @fan:直接使用 formRules;只要隐藏掉的字段,它是不会校验的哈; | ||||
| const getFormRules = async (type: string) => { | ||||
|   let formRulesObj = { | ||||
|     name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }], | ||||
|     avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }], | ||||
|     category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }], | ||||
|     sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }], | ||||
|     description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }], | ||||
|     systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }], | ||||
|     publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }] | ||||
|   } | ||||
| 
 | ||||
|   formRules.value = reactive(formRulesObj) | ||||
| } | ||||
| const formRules = reactive<FormRules>({ | ||||
|   name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }], | ||||
|   avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }], | ||||
|   category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }], | ||||
|   sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }], | ||||
|   description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }], | ||||
|   systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }], | ||||
|   publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }] | ||||
| }) | ||||
| 
 | ||||
| /** 打开弹窗 */ | ||||
| // TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理 | ||||
|  | @ -123,7 +117,6 @@ const open = async (type: string, id?: number, title?: string) => { | |||
|   dialogVisible.value = true | ||||
|   dialogTitle.value = title || t('action.' + type) | ||||
|   formType.value = type | ||||
|   getFormRules(type) | ||||
|   resetForm() | ||||
|   // 修改时,设置数据 | ||||
|   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"> | ||||
| import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' | ||||
| import { dateFormatter } from '@/utils/formatTime' | ||||
| import download from '@/utils/download' | ||||
| import { CategoryApi, CategoryVO } from '@/api/bpm/category' | ||||
| import CategoryForm from './CategoryForm.vue' | ||||
| 
 | ||||
|  |  | |||
|  | @ -36,9 +36,9 @@ | |||
|           value-format="YYYY-MM-DD HH:mm:ss" | ||||
|         /> | ||||
|       </el-form-item> | ||||
|       <el-form-item label="审批结果" prop="result"> | ||||
|       <el-form-item label="审批结果" prop="status"> | ||||
|         <el-select | ||||
|           v-model="queryParams.result" | ||||
|           v-model="queryParams.status" | ||||
|           class="!w-240px" | ||||
|           clearable | ||||
|           placeholder="请选择审批结果" | ||||
|  | @ -81,7 +81,7 @@ | |||
|   <ContentWrap> | ||||
|     <el-table v-loading="loading" :data="list"> | ||||
|       <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"> | ||||
|           <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" /> | ||||
|         </template> | ||||
|  |  | |||
 YunaiV
						YunaiV