Browse Source

Merge remote-tracking branch 'refs/remotes/yudao/dev' into dev-crm

puhui999 1 year ago
parent
commit
1abfec766e
52 changed files with 5101 additions and 832 deletions
  1. 1 1
      package.json
  2. 20 4
      pnpm-lock.yaml
  3. 4 0
      src/App.vue
  4. 22 12
      src/api/ai/chat/conversation/index.ts
  5. 26 5
      src/api/ai/chat/message/index.ts
  6. 107 0
      src/api/ai/image/index.ts
  7. 41 0
      src/api/ai/music/index.ts
  8. 1 0
      src/assets/ai/clear.svg
  9. 0 0
      src/assets/ai/gpt.svg
  10. 4 5
      src/components/ContentWrap/src/ContentWrap.vue
  11. 1 1
      src/components/Icon/src/Icon.vue
  12. 26 4
      src/components/MarkdownView/index.vue
  13. 1 1
      src/layout/Layout.vue
  14. 6 6
      src/layout/components/AppView.vue
  15. 20 0
      src/router/modules/remaining.ts
  16. 12 2
      src/utils/dict.ts
  17. 79 0
      src/views/ai/chat/ChatEmpty.vue
  18. 521 0
      src/views/ai/chat/Conversation.vue
  19. 322 0
      src/views/ai/chat/Message.vue
  20. 15 0
      src/views/ai/chat/MessageLoading.vue
  21. 50 0
      src/views/ai/chat/MessageNewChat.vue
  22. 1 1
      src/views/ai/chat/components/ChatConversationUpdateForm.vue
  23. 0 2
      src/views/ai/chat/components/Header.vue
  24. 0 156
      src/views/ai/chat/components/MessageList.vue
  25. 438 465
      src/views/ai/chat/index.vue
  26. 163 0
      src/views/ai/chat/manager/ChatConversationList.vue
  27. 175 0
      src/views/ai/chat/manager/ChatMessageList.vue
  28. 20 0
      src/views/ai/chat/manager/index.vue
  29. 6 5
      src/views/ai/chat/role/RoleCategoryList.vue
  30. 73 40
      src/views/ai/chat/role/RoleList.vue
  31. 149 72
      src/views/ai/chat/role/index.vue
  32. 141 0
      src/views/ai/image/ImageDetailDrawer.vue
  33. 244 0
      src/views/ai/image/ImageTask.vue
  34. 145 0
      src/views/ai/image/ImageTaskCard.vue
  35. 398 0
      src/views/ai/image/dall3/index.vue
  36. 105 0
      src/views/ai/image/index.vue
  37. 250 0
      src/views/ai/image/manager/index.vue
  38. 384 0
      src/views/ai/image/midjourney/index.vue
  39. 405 0
      src/views/ai/image/stable-diffusion/index.vue
  40. 19 3
      src/views/ai/model/chatModel/ChatModelForm.vue
  41. 27 42
      src/views/ai/model/chatRole/ChatRoleForm.vue
  42. 21 0
      src/views/ai/music/components/index.vue
  43. 9 0
      src/views/ai/music/components/list/audioBar/index.vue
  44. 94 0
      src/views/ai/music/components/list/index.vue
  45. 29 0
      src/views/ai/music/components/list/songCard/index.vue
  46. 33 0
      src/views/ai/music/components/list/songInfo/index.vue
  47. 55 0
      src/views/ai/music/components/mode/desc.vue
  48. 44 0
      src/views/ai/music/components/mode/index.vue
  49. 83 0
      src/views/ai/music/components/mode/lyric.vue
  50. 25 0
      src/views/ai/music/components/title/index.vue
  51. 285 0
      src/views/ai/music/manager/index.vue
  52. 1 5
      src/views/infra/job/index.vue

+ 1 - 1
package.json

@@ -47,7 +47,7 @@
     "driver.js": "^1.3.1",
     "echarts": "^5.5.0",
     "echarts-wordcloud": "^2.1.0",
-    "element-plus": "2.6.1",
+    "element-plus": "2.7.0",
     "fast-xml-parser": "^4.3.2",
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",

+ 20 - 4
pnpm-lock.yaml

@@ -17,6 +17,9 @@ dependencies:
   '@iconify/iconify':
     specifier: ^3.1.1
     version: 3.1.1
+  '@microsoft/fetch-event-source':
+    specifier: ^2.0.1
+    version: 2.0.1
   '@videojs-player/vue':
     specifier: ^1.0.0
     version: 1.0.0(@types/video.js@7.3.58)(video.js@7.21.5)(vue@3.4.21)
@@ -69,8 +72,8 @@ dependencies:
     specifier: ^2.1.0
     version: 2.1.0(echarts@5.5.0)
   element-plus:
-    specifier: 2.6.1
-    version: 2.6.1(vue@3.4.21)
+    specifier: 2.7.0
+    version: 2.7.0(vue@3.4.21)
   fast-xml-parser:
     specifier: ^4.3.2
     version: 4.3.6
@@ -83,6 +86,9 @@ dependencies:
   lodash-es:
     specifier: ^4.17.21
     version: 4.17.21
+  marked:
+    specifier: ^12.0.2
+    version: 12.0.2
   min-dash:
     specifier: ^4.1.1
     version: 4.2.1
@@ -2360,6 +2366,10 @@ packages:
       '@jridgewell/sourcemap-codec': 1.4.15
     dev: true
 
+  /@microsoft/fetch-event-source@2.0.1:
+    resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==, tarball: https://registry.npmmirror.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz}
+    dev: false
+
   /@nodelib/fs.scandir@2.1.5:
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, tarball: https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz}
     engines: {node: '>= 8'}
@@ -5082,8 +5092,8 @@ packages:
     resolution: {integrity: sha512-9ItEpeu15hW5m8jKdriL+BQrgwDTXEL9pn4SkillWFu73ZNNNQ2BKKLS+ZHv2vC9UkNhosAeyfxOf/5OSeTCPA==, tarball: https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.750.tgz}
     dev: true
 
-  /element-plus@2.6.1(vue@3.4.21):
-    resolution: {integrity: sha512-6VRpLjwtIVdtUuITJPPKtpOH1NM6nuAkRE3q5O4Lrx0N1bYMhTkiqb2Jy7zfQuDPbOIkkF2OABTzegpNnzgsnQ==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.6.1.tgz}
+  /element-plus@2.7.0(vue@3.4.21):
+    resolution: {integrity: sha512-WAiaFLavuWFxof9qwkC27jvkh9nRcNnB506g1vvJSiVaVqjCBWUFCIyJKeN11M1qcv2cS5VV5PfSLjTIkrw87A==, tarball: https://registry.npmmirror.com/element-plus/-/element-plus-2.7.0.tgz}
     peerDependencies:
       vue: ^3.2.0
     dependencies:
@@ -6917,6 +6927,12 @@ packages:
       object-visit: 1.0.1
     dev: true
 
+  /marked@12.0.2:
+    resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==, tarball: https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz}
+    engines: {node: '>= 18'}
+    hasBin: true
+    dev: false
+
   /matches-selector@1.2.0:
     resolution: {integrity: sha512-c4vLwYWyl+Ji+U43eU/G5FwxWd4ZH0ePUsFs5y0uwD9HUEFBXUQ1zUUan+78IpRD+y4pUfG0nAzNM292K7ItvA==, tarball: https://registry.npmmirror.com/matches-selector/-/matches-selector-1.2.0.tgz}
     dev: true

+ 4 - 0
src/App.vue

@@ -54,4 +54,8 @@ body {
 .#{$prefix-cls}-grey-mode {
   filter: grayscale(100%);
 }
+
+.scrollbar__view {
+  height: 99%!important;
+}
 </style>

+ 22 - 12
src/api/ai/chat/conversation/index.ts

@@ -1,10 +1,10 @@
 import request from '@/config/axios'
 
-// AI 聊天话 VO
+// AI 聊天话 VO
 export interface ChatConversationVO {
-  id: number // ID 编号
+  id: string // ID 编号
   userId: number // 用户编号
-  title: string // 话标题
+  title: string // 话标题
   pinned: boolean // 是否置顶
   roleId: number // 角色编号
   modelId: number // 模型编号
@@ -12,33 +12,33 @@ export interface ChatConversationVO {
   temperature: number // 温度参数
   maxTokens: number // 单条回复的最大 Token 数量
   maxContexts: number // 上下文的最大 Message 数量
-  updateTime: number // 更新时间
   // 额外字段
+  systemMessage?: string // 角色设定
   modelName?: string // 模型名字
   roleAvatar?: string // 角色头像
   modelMaxTokens?: string // 模型的单条回复的最大 Token 数量
   modelMaxContexts?: string // 模型的上下文的最大 Message 数量
 }
 
-// AI 聊天话 API
+// AI 聊天话 API
 export const ChatConversationApi = {
-  // 获得【我的】聊天
-  getChatConversationMy: async (id: number) => {
+  // 获得【我的】聊天
+  getChatConversationMy: async (id: string) => {
     return await request.get({ url: `/ai/chat/conversation/get-my?id=${id}` })
   },
 
-  // 新增【我的】聊天
+  // 新增【我的】聊天
   createChatConversationMy: async (data?: ChatConversationVO) => {
     return await request.post({ url: `/ai/chat/conversation/create-my`, data })
   },
 
-  // 更新【我的】聊天
+  // 更新【我的】聊天
   updateChatConversationMy: async (data: ChatConversationVO) => {
     return await request.put({ url: `/ai/chat/conversation/update-my`, data })
   },
 
-  // 删除【我的】聊天
-  deleteChatConversationMy: async (id: number) => {
+  // 删除【我的】聊天
+  deleteChatConversationMy: async (id: string) => {
     return await request.delete({ url: `/ai/chat/conversation/delete-my?id=${id}` })
   },
 
@@ -47,8 +47,18 @@ export const ChatConversationApi = {
     return await request.delete({ url: `/ai/chat/conversation/delete-my-all-except-pinned` })
   },
 
-  // 获得【我的】聊天话列表
+  // 获得【我的】聊天话列表
   getChatConversationMyList: async () => {
     return await request.get({ url: `/ai/chat/conversation/my-list` })
+  },
+
+  // 获得对话分页
+  getChatConversationPage: async (params: any) => {
+    return await request.get({ url: `/ai/chat/conversation/page`, params })
+  },
+
+  // 管理员删除消息
+  deleteChatConversationByAdmin: async (id: number) => {
+    return await request.delete({ url: `/ai/chat/conversation/delete-by-admin?id=${id}` })
   }
 }

+ 26 - 5
src/api/ai/chat/message/index.ts

@@ -6,7 +6,7 @@ import { config } from '@/config/axios/config'
 // 聊天VO
 export interface ChatMessageVO {
   id: number // 编号
-  conversationId: number // 话编号
+  conversationId: number // 话编号
   type: string // 消息类型
   userId: string // 用户编号
   roleId: string // 角色编号
@@ -15,17 +15,19 @@ export interface ChatMessageVO {
   content: string // 聊天内容
   tokens: number // 消耗 Token 数量
   createTime: Date // 创建时间
+  roleAvatar: string // 角色头像
+  userAvatar: string // 创建时间
 }
 
 export interface ChatMessageSendVO {
-  conversationId: string // 话编号
+  conversationId: string // 话编号
   content: number // 聊天内容
 }
 
 // AI chat 聊天
 export const ChatMessageApi = {
   // 消息列表
-  messageList: async (conversationId: number | null) => {
+  messageList: async (conversationId: string | null) => {
     return await request.get({
       url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`
     })
@@ -37,6 +39,7 @@ export const ChatMessageApi = {
     conversationId: number,
     content: string,
     ctrl,
+    enableContext: boolean,
     onMessage,
     onError,
     onClose
@@ -51,7 +54,8 @@ export const ChatMessageApi = {
       openWhenHidden: true,
       body: JSON.stringify({
         conversationId,
-        content
+        content,
+        useContext: enableContext
       }),
       onmessage: onMessage,
       onerror: onError,
@@ -60,8 +64,25 @@ export const ChatMessageApi = {
     })
   },
 
-  // 发送 send 消息
+  // 删除消息
   delete: async (id: string) => {
     return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
+  },
+
+  // 删除消息 - 对话所有消息
+  deleteByConversationId: async (conversationId: string) => {
+    return await request.delete({
+      url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`
+    })
+  },
+
+  // 获得消息分页
+  getChatMessagePage: async (params: any) => {
+    return await request.get({ url: '/ai/chat/message/page', params })
+  },
+
+  // 管理员删除消息
+  deleteChatMessageByAdmin: async (id: number) => {
+    return await request.delete({ url: `/ai/chat/message/delete-by-admin?id=${id}` })
   }
 }

+ 107 - 0
src/api/ai/image/index.ts

@@ -0,0 +1,107 @@
+import request from '@/config/axios'
+
+// AI 绘图 VO
+export interface ImageVO {
+  id: number // 编号
+  platform: string // 平台
+  model: string // 模型
+  prompt: string // 提示词
+  width: number // 图片宽度
+  height: number // 图片高度
+  status: number // 状态
+  publicStatus: boolean // 公开状态
+  picUrl: string // 任务地址
+  errorMessage: string // 错误信息
+  options: object // 配置 Map<string, string>
+  taskId: number // 任务编号
+  buttons: ImageMjButtonsVO[] // mj 操作按钮
+  createTime: string // 创建时间
+}
+
+export interface ImagePageReqVO {
+  pageNo: number // 分页编号
+  pageSize: number // 分页大小
+}
+
+export interface ImageDrawReqVO {
+  platform: string // 平台
+  prompt: string // 提示词
+  model: string // 模型
+  style: string // 图像生成的风格
+  width: string // 图片宽度
+  height: string // 图片高度
+  options: object // 绘制参数,Map<String, String>
+}
+
+export interface ImageMidjourneyImagineReqVO {
+  prompt: string // 提示词
+  model: string // 模型 mj nijj
+  base64Array: string[] // size不能为空
+  width: string // 图片宽度
+  height: string // 图片高度
+  version: string // 版本
+}
+
+export interface ImageMjActionVO {
+  id: number // 图片编号
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+}
+
+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)
+}
+
+// AI API 密钥 API
+export const ImageApi = {
+  // 获取【我的】绘图分页
+  getImagePageMy: async (params: ImagePageReqVO) => {
+    return await request.get({ url: `/ai/image/my-page`, params })
+  },
+  // 获取【我的】绘图记录
+  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) => {
+    return await request.post({ url: `/ai/image/draw`, data })
+  },
+  // 删除【我的】绘画记录
+  deleteImageMy: async (id: number) => {
+    return await request.delete({ url: `/ai/image/delete-my?id=${id}` })
+  },
+
+  // ================ midjourney 专属 ================
+
+  // 【Midjourney】生成图片
+  midjourneyImagine: async (data: ImageMidjourneyImagineReqVO) => {
+    return await request.post({ url: `/ai/image/midjourney/imagine`, data })
+  },
+  // 【Midjourney】Action 操作(二次生成图片)
+  midjourneyAction: async (data: ImageMjActionVO) => {
+    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-public-status', data })
+  },
+
+  // 删除绘画
+  deleteImage: async (id: number) => {
+    return await request.delete({ url: `/ai/image/delete?id=` + id })
+  }
+}

+ 41 - 0
src/api/ai/music/index.ts

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

+ 1 - 0
src/assets/ai/clear.svg

@@ -0,0 +1 @@
+<?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>

File diff suppressed because it is too large
+ 0 - 0
src/assets/ai/gpt.svg


+ 4 - 5
src/components/ContentWrap/src/ContentWrap.vue

@@ -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 - 1
src/components/Icon/src/Icon.vue

@@ -22,7 +22,7 @@ const props = defineProps({
 
 const elRef = ref<ElRef>(null)
 
-const isLocal = computed(() => props.icon.startsWith('svg-icon:'))
+const isLocal = computed(() => props.icon?.startsWith('svg-icon:'))
 
 const symbolId = computed(() => {
   return unref(isLocal) ? `#icon-${props.icon.split('svg-icon:')[1]}` : props.icon

+ 26 - 4
src/components/MarkdownView/index.vue

@@ -1,23 +1,33 @@
 
 <template>
-  <div v-html="contentHtml"></div>
+  <div ref="contentRef" class="markdown-view" v-html="contentHtml"></div>
 </template>
 
 <script setup lang="ts">
+import {useClipboard} from "@vueuse/core";
+
 import {marked} from 'marked'
 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) {
-    const highlightHtml = hljs.highlight(code, {language: language, ignoreIllegals: true}).value
+    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>${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
+    return `<pre style="position: relative;">${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
   }
 }
 
@@ -54,11 +64,23 @@ const renderMarkdown = async (content: string) => {
 onMounted(async ()  => {
   // 解析转换 markdown
   await renderMarkdown(props.content as string);
+  //
+  // 添加 copy 监听
+  contentRef.value.addEventListener('click', (e: any) => {
+    console.log(e)
+    if (e.target.id === 'copy') {
+      copy(e.target?.dataset?.copy)
+      ElMessage({
+        message: '复制成功!',
+        type: 'success'
+      })
+    }
+  })
 })
 </script>
 
 
-<style scoped lang="scss">
+<style lang="scss">
 .markdown-view {
   font-family: PingFang SC;
   font-size: 0.95rem;

+ 1 - 1
src/layout/Layout.vue

@@ -72,7 +72,7 @@ $prefix-cls: #{$namespace}-layout;
 .#{$prefix-cls} {
   background-color: var(--app-content-bg-color);
   :deep(.#{$elNamespace}-scrollbar__view) {
-    height: 100% !important;
+    height: 99% !important;
   }
 }
 </style>

+ 6 - 6
src/layout/components/AppView.vue

@@ -38,24 +38,24 @@ provide('reload', reload)
     :class="[
       'p-[var(--app-content-padding)] w-[calc(100%-var(--app-content-padding)-var(--app-content-padding))] bg-[var(--app-content-bg-color)] dark:bg-[var(--el-bg-color)]',
       {
-        '!min-h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]':
+        '!h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]':
           (fixedHeader &&
             (layout === 'classic' || layout === 'topLeft' || layout === 'top') &&
             footer) ||
           (!tagsView && layout === 'top' && footer),
-        '!min-h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height)-var(--tags-view-height))]':
+        '!h-[calc(100%-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height)-var(--tags-view-height))]':
           tagsView && layout === 'top' && footer,
 
-        '!min-h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--top-tool-height)-var(--app-footer-height))]':
+        '!h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--top-tool-height)-var(--app-footer-height))]':
           !fixedHeader && layout === 'classic' && footer,
 
-        '!min-h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]':
+        '!h-[calc(100%-var(--tags-view-height)-var(--app-content-padding)-var(--app-content-padding)-var(--app-footer-height))]':
           !fixedHeader && layout === 'topLeft' && footer,
 
-        '!min-h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding))]':
+        '!h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding))]':
           fixedHeader && layout === 'cutMenu' && footer,
 
-        '!min-h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding)-var(--tags-view-height))]':
+        '!h-[calc(100%-var(--top-tool-height)-var(--app-content-padding)-var(--app-content-padding)-var(--tags-view-height))]':
           !fixedHeader && layout === 'cutMenu' && footer
       }
     ]"

+ 20 - 0
src/router/modules/remaining.ts

@@ -70,6 +70,26 @@ const remainingRouter: AppRouteRecordRaw[] = [
       }
     ]
   },
+  {
+    path: '/ai/music',
+    component: Layout,
+    redirect: '/index',
+    name: 'AIMusic',
+    meta: {},
+    children: [
+      {
+        path: 'index',
+        component: () => import('@/views/ai/music/components/index.vue'),
+        name: 'AIMusicIndex',
+        meta: {
+          title: 'AI 音乐',
+          icon: 'ep:home-filled',
+          noCache: false,
+          affix: true
+        }
+      }
+    ]
+  },
   {
     path: '/user',
     component: Layout,

+ 12 - 2
src/utils/dict.ts

@@ -24,6 +24,10 @@ export interface NumberDictDataType extends DictDataType {
   value: number
 }
 
+export interface StringDictDataType extends DictDataType {
+  value: string
+}
+
 export const getDictOptions = (dictType: string) => {
   return dictStore.getDictByType(dictType) || []
 }
@@ -44,8 +48,11 @@ export const getIntDictOptions = (dictType: string): NumberDictDataType[] => {
 }
 
 export const getStrDictOptions = (dictType: string) => {
-  const dictOption: DictDataType[] = []
+  // 获得通用的 DictDataType 列表
   const dictOptions: DictDataType[] = getDictOptions(dictType)
+  // 转换成 string 类型的 StringDictDataType 类型
+  // why 需要特殊转换:避免 IDEA 在 v-for="dict in getStrDictOptions(...)" 时,el-option 的 key 会告警
+  const dictOption: StringDictDataType[] = []
   dictOptions.forEach((dict: DictDataType) => {
     dictOption.push({
       ...dict,
@@ -212,5 +219,8 @@ 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 生成模式
 }

+ 79 - 0
src/views/ai/chat/ChatEmpty.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="chat-empty">
+
+    <!--  title  -->
+    <div class="center-container">
+      <div class="title">芋艿 AI</div>
+      <div class="role-list">
+        <div class="role-item" v-for="prompt in promptList" :key="prompt.prompt" @click="handlerPromptClick(prompt)">
+          {{prompt.prompt}}
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+
+const promptList = ref<any[]>() // 角色列表
+promptList.value = [
+  {
+    "prompt": "今天气怎么样?",
+  },
+  {
+    "prompt": "写一首好听的诗歌?",
+  }
+]
+
+const emits = defineEmits(['onPrompt'])
+
+const handlerPromptClick = async ({ prompt }) => {
+  emits('onPrompt', prompt)
+}
+</script>
+<style scoped lang="scss">
+.chat-empty {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+
+  .center-container {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    .title {
+      font-size: 28px;
+      font-weight: bold;
+      text-align: center;
+    }
+
+    .role-list {
+      display: flex;
+      flex-direction: row;
+      flex-wrap: wrap;
+      align-items: center;
+      justify-content: center;
+      width: 460px;
+      margin-top: 20px;
+
+      .role-item {
+        display: flex;
+        justify-content: center;
+        width: 180px;
+        line-height: 50px;
+        border: 1px solid #e4e4e4;
+        border-radius: 10px;
+        margin: 10px;
+        cursor: pointer;
+      }
+
+      .role-item:hover {
+        background-color: rgba(243, 243, 243, 0.73);
+      }
+    }
+  }
+}
+</style>

+ 521 - 0
src/views/ai/chat/Conversation.vue

@@ -0,0 +1,521 @@
+<!--  AI 对话  -->
+<template>
+  <el-aside width="260px" class="conversation-container" style="height: 100%;">
+
+    <!-- 左顶部:对话 -->
+    <div style="height: 100%;">
+      <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
+        <Icon icon="ep:plus" class="mr-5px"/>
+        新建对话
+      </el-button>
+
+      <!-- 左顶部:搜索对话 -->
+      <el-input
+        v-model="searchName"
+        size="large"
+        class="mt-10px search-input"
+        placeholder="搜索历史记录"
+        @keyup="searchConversation"
+      >
+        <template #prefix>
+          <Icon icon="ep:search"/>
+        </template>
+      </el-input>
+
+      <!-- 左中间:对话列表 -->
+      <div class="conversation-list">
+
+        <el-empty v-if="loading" description="." :v-loading="loading" />
+
+        <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
+          <div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length">
+            <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
+          </div>
+          <div
+            class="conversation-item"
+            v-for="conversation in conversationMap[conversationKey]"
+            :key="conversation.id"
+            @click="handleConversationClick(conversation.id)"
+            @mouseover="hoverConversationId = conversation.id"
+            @mouseout="hoverConversationId = ''"
+          >
+            <div
+              :class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'"
+            >
+              <div class="title-wrapper">
+                <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg"/>
+                <span class="title">{{ conversation.title }}</span>
+              </div>
+              <div class="button-wrapper" v-show="hoverConversationId === conversation.id">
+                <el-button class="btn" link @click.stop="handlerTop(conversation)" >
+                  <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
+                  <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
+                </el-button>
+                <el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
+                  <el-icon title="编辑" >
+                    <Icon icon="ep:edit"/>
+                  </el-icon>
+                </el-button>
+                <el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
+                  <el-icon title="删除对话" >
+                    <Icon icon="ep:delete"/>
+                  </el-icon>
+                </el-button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!--  底部站位  -->
+        <div style="height: 160px; width: 100%;"></div>
+      </div>
+
+    </div>
+
+    <!-- 左底部:工具栏 -->
+    <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
+    <div class="tool-box">
+      <div @click="handleRoleRepository">
+        <Icon icon="ep:user"/>
+        <el-text size="small">角色仓库</el-text>
+      </div>
+      <div @click="handleClearConversation">
+        <Icon icon="ep:delete"/>
+        <el-text size="small">清空未置顶对话</el-text>
+      </div>
+    </div>
+
+    <!-- ============= 额外组件 ============= -->
+
+    <!-- 角色仓库抽屉 -->
+    <el-drawer v-model="drawer" title="角色仓库" size="754px">
+      <Role/>
+    </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 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 loading = ref<boolean>(false) // 加载中
+const loadingTime = ref<any>() // 加载中定时器
+
+// 定义组件 props
+const props = defineProps({
+  activeId: {
+    type: String || null,
+    required: true
+  }
+})
+
+// 定义钩子
+const emits = defineEmits([
+  'onConversationCreate',
+  'onConversationClick',
+  'onConversationClear',
+  'onConversationDelete'
+])
+
+/**
+ * 对话 - 搜索
+ */
+const searchConversation = async (e) => {
+  // 恢复数据
+  if (!searchName.value.trim().length) {
+    conversationMap.value = await conversationTimeGroup(conversationList.value)
+  } else {
+    // 过滤
+    const filterValues = conversationList.value.filter(item => {
+      return item.title.includes(searchName.value.trim())
+    })
+    conversationMap.value = await conversationTimeGroup(filterValues)
+  }
+}
+
+/**
+ * 对话 - 点击
+ */
+const handleConversationClick = async (id: string) => {
+  // 过滤出选中的对话
+  const filterConversation = conversationList.value.filter(item => {
+    return item.id === id
+  })
+  // 回调 onConversationClick
+  // TODO @fan: 这里 idea 会报黄色警告,有办法解下么?
+  const res = emits('onConversationClick', filterConversation[0])
+  // 切换对话
+  if (res) {
+    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) => {
+      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. 没有任何对话情况
+    if (conversationList.value.length === 0) {
+      activeConversationId.value = null
+      conversationMap.value = {}
+      return
+    }
+    // 5. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30天前)
+    conversationMap.value = await conversationTimeGroup(conversationList.value)
+  } finally {
+    // 清理定时器
+    if (loadingTime.value) {
+      clearTimeout(loadingTime.value)
+    }
+    // 加载完成
+    loading.value = false
+  }
+}
+
+const conversationTimeGroup = async (list: ChatConversationVO[]) => {
+  // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
+  const groupMap = {
+    '置顶': [],
+    '今天': [],
+    '一天前': [],
+    '三天前': [],
+    '七天前': [],
+    '三十天前': []
+  }
+  // 当前时间的时间戳
+  const now = Date.now();
+  // 定义时间间隔常量(单位:毫秒)
+  const oneDay = 24 * 60 * 60 * 1000;
+  const threeDays = 3 * oneDay;
+  const sevenDays = 7 * oneDay;
+  const thirtyDays = 30 * oneDay;
+  for (const conversation: ChatConversationVO of list) {
+    // 置顶
+    if (conversation.pinned) {
+      groupMap['置顶'].push(conversation)
+      continue
+    }
+    // 计算时间差(单位:毫秒)
+    const diff = now - conversation.updateTime;
+    // 根据时间间隔判断
+    if (diff < oneDay) {
+      groupMap['今天'].push(conversation)
+    } else if (diff < threeDays) {
+      groupMap['一天前'].push(conversation)
+    } else if (diff < sevenDays) {
+      groupMap['三天前'].push(conversation)
+    } else if (diff < thirtyDays) {
+      groupMap['七天前'].push(conversation)
+    } else {
+      groupMap['三十天前'].push(conversation)
+    }
+  }
+  console.log('----groupMap', groupMap)
+  return groupMap
+}
+
+/**
+ * 对话 - 新建
+ */
+const createConversation = async () => {
+  // 1. 新建对话
+  const conversationId = await ChatConversationApi.createChatConversationMy(
+    {} as unknown as ChatConversationVO
+  )
+  // 2. 获取对话内容
+  await getChatConversationList()
+  // 3. 选中对话
+  await handleConversationClick(conversationId)
+  // 4. 回调
+  emits('onConversationCreate')
+}
+
+/**
+ * 对话 - 更新标题
+ */
+const updateConversationTitle = async (conversation: ChatConversationVO) => {
+  // 1. 二次确认
+  const {value} = await ElMessageBox.prompt('修改标题', {
+    inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
+    inputErrorMessage: '标题不能为空',
+    inputValue: conversation.title
+  })
+  // 2. 发起修改
+  await ChatConversationApi.updateChatConversationMy({
+    id: conversation.id,
+    title: value
+  } as ChatConversationVO)
+  message.success('重命名成功')
+  // 3. 刷新列表
+  await getChatConversationList()
+  // 4. 过滤当前切换的
+  const filterConversationList = conversationList.value.filter(item => {
+    return item.id === conversation.id
+  })
+  if (filterConversationList.length > 0) {
+    // tip:避免切换对话
+    if (activeConversationId.value === filterConversationList[0].id) {
+      emits('onConversationClick', filterConversationList[0])
+    }
+  }
+}
+
+/**
+ * 删除聊天对话
+ */
+const deleteChatConversation = async (conversation: ChatConversationVO) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`)
+    // 发起删除
+    await ChatConversationApi.deleteChatConversationMy(conversation.id)
+    message.success('对话已删除')
+    // 刷新列表
+    await getChatConversationList()
+    // 回调
+    emits('onConversationDelete', conversation)
+  } catch {
+  }
+}
+
+/**
+ * 对话置顶
+ */
+// TODO @fan:应该是 handleXXX,handler 是名词哈
+const handlerTop = async (conversation: ChatConversationVO) => {
+  // 更新对话置顶
+  conversation.pinned = !conversation.pinned
+  await ChatConversationApi.updateChatConversationMy(conversation)
+  // 刷新对话
+  await getChatConversationList()
+}
+
+// TODO @fan:类似 ============ 分块的,最后后面也有 ============ 哈
+// ============ 角色仓库
+
+/**
+ * 角色仓库抽屉
+ */
+const handleRoleRepository = async () => {
+  drawer.value = !drawer.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})
+
+onMounted(async () => {
+  // 获取 对话列表
+  await getChatConversationList()
+  // 默认选中
+  if (props.activeId != null) {
+    activeConversationId.value = props.activeId
+  } else {
+    // 首次默认选中第一个
+    if (conversationList.value.length) {
+      activeConversationId.value = conversationList.value[0].id
+      // 回调 onConversationClick
+      await emits('onConversationClick', conversationList.value[0])
+    }
+  }
+})
+
+</script>
+
+<style scoped lang="scss">
+
+.conversation-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 0 10px;
+  padding-top: 10px;
+  overflow: hidden;
+
+  .btn-new-conversation {
+    padding: 18px 0;
+  }
+
+  .search-input {
+    margin-top: 20px;
+  }
+
+  .conversation-list {
+    overflow: auto;
+    height: 100%;
+
+    .classify-title {
+      padding-top: 10px;
+    }
+
+    .conversation-item {
+      margin-top: 5px;
+    }
+
+    .conversation {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      flex: 1;
+      padding: 0 5px;
+      cursor: pointer;
+      border-radius: 5px;
+      align-items: center;
+      line-height: 30px;
+
+      &.active {
+        background-color: #e6e6e6;
+
+        .button {
+          display: inline-block;
+        }
+      }
+
+      .title-wrapper {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+      }
+
+      .title {
+        padding: 2px 10px;
+        max-width: 220px;
+        font-size: 14px;
+        font-weight: 400;
+        color: rgba(0, 0, 0, 0.77);
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      .avatar {
+        width: 25px;
+        height: 25px;
+        border-radius: 5px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+      }
+
+      // 对话编辑、删除
+      .button-wrapper {
+        right: 2px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+        color: #606266;
+
+        .btn {
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  // 角色仓库、清空未设置对话
+  .tool-box {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    //width: 100%;
+    padding: 0 20px;
+    background-color: #f4f4f4;
+    box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8);
+    line-height: 35px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: var(--el-text-color);
+
+    > div {
+      display: flex;
+      align-items: center;
+      color: #606266;
+      padding: 0;
+      margin: 0;
+      cursor: pointer;
+
+      > span {
+        margin-left: 5px;
+      }
+    }
+  }
+}
+</style>

+ 322 - 0
src/views/ai/chat/Message.vue

@@ -0,0 +1,322 @@
+<template>
+  <div ref="messageContainer" style="height: 100%; overflow-y: auto; position: relative">
+    <div class="chat-list" v-for="(item, index) in list" :key="index">
+      <!--  靠左 message  -->
+      <div class="left-message message-item" v-if="item.type !== 'user'">
+        <div class="avatar">
+          <el-avatar :src="roleAvatar" />
+        </div>
+        <div class="message">
+          <div>
+            <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
+          </div>
+          <div class="left-text-container" ref="markdownViewRef">
+            <MarkdownView class="left-text" :content="item.content" />
+          </div>
+          <div class="left-btns">
+            <el-button class="btn-cus" link @click="noCopy(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>
+          </div>
+        </div>
+      </div>
+      <!--  靠右 message  -->
+      <div class="right-message message-item" v-if="item.type === 'user'">
+        <div class="avatar">
+          <el-avatar :src="userAvatar" />
+        </div>
+        <div class="message">
+          <div>
+            <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
+          </div>
+          <div class="right-text-container">
+            <div class="right-text">{{ item.content }}</div>
+          </div>
+          <div class="right-btns">
+            <el-button class="btn-cus" link @click="noCopy(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"
+              />
+            </el-button>
+            <el-button class="btn-cus" link @click="onRefresh(item)">
+              <el-icon size="17"><RefreshRight /></el-icon>
+            </el-button>
+            <el-button class="btn-cus" link @click="onEdit(item)">
+              <el-icon size="17"><Edit /></el-icon>
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <!-- 回到底部 -->
+  <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
+    <el-button :icon="ArrowDownBold" circle />
+  </div>
+</template>
+<script setup lang="ts">
+import { 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 { ChatConversationVO } from '@/api/ai/chat/conversation'
+import {useUserStore} from '@/store/modules/user';
+import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
+import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
+
+const { copy } = useClipboard() // 初始化 copy 到粘贴板
+// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
+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)
+
+// 定义 props
+const props = defineProps({
+  conversation: {
+    type: Object as PropType<ChatConversationVO>,
+    required: true
+  },
+  list: {
+    type: Array as PropType<ChatMessageVO[]>,
+    required: true
+  }
+})
+
+// ============ 处理对话滚动 ==============
+
+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
+    }
+  })
+}
+
+function handleScroll() {
+  const scrollContainer = messageContainer.value
+  const scrollTop = scrollContainer.scrollTop
+  const scrollHeight = scrollContainer.scrollHeight
+  const offsetHeight = scrollContainer.offsetHeight
+  if (scrollTop + offsetHeight < scrollHeight - 100) {
+    // 用户开始滚动并在最底部之上,取消保持在最底部的效果
+    isScrolling.value = true
+  } else {
+    // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
+    isScrolling.value = false
+  }
+}
+
+/**
+ * 复制
+ */
+const 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)
+})
+
+// 提供方法给 parent 调用
+defineExpose({ scrollToBottom, handlerGoTop })
+
+// 定义 emits
+const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit'])
+
+// onMounted
+onMounted(async () => {
+  messageContainer.value.addEventListener('scroll', handleScroll)
+})
+</script>
+
+<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;
+}
+
+// 中间
+.chat-list {
+  display: flex;
+  flex-direction: column;
+  overflow-y: hidden;
+  padding: 0 20px;
+  .message-item {
+    margin-top: 50px;
+  }
+
+  .left-message {
+    display: flex;
+    flex-direction: row;
+  }
+
+  .right-message {
+    display: flex;
+    flex-direction: row-reverse;
+    justify-content: flex-start;
+  }
+
+  .avatar {
+    //height: 170px;
+    //width: 170px;
+  }
+
+  .message {
+    display: flex;
+    flex-direction: column;
+    text-align: left;
+    margin: 0 15px;
+
+    .time {
+      text-align: left;
+      line-height: 30px;
+    }
+
+    .left-text-container {
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      overflow-wrap: break-word;
+      background-color: rgba(228, 228, 228, 0.8);
+      box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8);
+      border-radius: 10px;
+      padding: 10px 10px 5px 10px;
+
+      .left-text {
+        color: #393939;
+        font-size: 0.95rem;
+      }
+    }
+
+    .right-text-container {
+      display: flex;
+      flex-direction: row-reverse;
+
+      .right-text {
+        font-size: 0.95rem;
+        color: #fff;
+        display: inline;
+        background-color: #267fff;
+        color: #fff;
+        box-shadow: 0 0 0 1px #267fff;
+        border-radius: 10px;
+        padding: 10px;
+        width: auto;
+        overflow-wrap: break-word;
+        white-space: pre-wrap;
+      }
+    }
+
+    .left-btns {
+      display: flex;
+      flex-direction: row;
+      margin-top: 8px;
+    }
+
+    .right-btns {
+      display: flex;
+      flex-direction: row-reverse;
+      margin-top: 8px;
+    }
+  }
+
+  // 复制、删除按钮
+  .btn-cus {
+    display: flex;
+    background-color: transparent;
+    align-items: center;
+
+    .btn-image {
+      height: 20px;
+    }
+  }
+
+  .btn-cus:hover {
+    cursor: pointer;
+    background-color: #f6f6f6;
+  }
+}
+
+// 回到底部
+.to-bottom {
+  position: absolute;
+  z-index: 1000;
+  bottom: 0;
+  right: 50%;
+}
+</style>

+ 15 - 0
src/views/ai/chat/MessageLoading.vue

@@ -0,0 +1,15 @@
+<!-- message 加载页面 -->
+<template>
+  <div class="message-loading" >
+    <el-skeleton animated />
+  </div>
+</template>
+
+<script setup lang="ts">
+
+</script>
+<style scoped lang="scss">
+.message-loading {
+  padding: 30px 30px;
+}
+</style>

+ 50 - 0
src/views/ai/chat/MessageNewChat.vue

@@ -0,0 +1,50 @@
+<!-- message 新增对话 -->
+<template>
+  <div class="new-chat" >
+    <div class="box-center">
+      <div class="tip">点击下方按钮,开始你的对话吧</div>
+      <div class="btns"><el-button type="primary" round @click="handlerNewChat">新建对话</el-button></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+// 定义钩子
+const emits = defineEmits(['onNewChat'])
+
+/**
+ * 新建 chat
+ */
+const handlerNewChat = async () => {
+  await emits('onNewChat')
+}
+
+</script>
+<style scoped lang="scss">
+.new-chat {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+
+  .box-center {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
+    .tip {
+      font-size: 14px;
+      color: #858585;
+    }
+
+    .btns {
+      display: flex;
+      flex-direction: row;
+      justify-content: center;
+      margin-top: 20px;
+    }
+  }
+}
+</style>

+ 1 - 1
src/views/ai/chat/components/ChatConversationUpdateForm.vue

@@ -8,7 +8,7 @@
       v-loading="formLoading"
     >
       <el-form-item label="角色设定" prop="systemMessage">
-        <el-input type="textarea" v-model="formData.systemMessage" 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="请选择模型">

+ 0 - 2
src/views/ai/chat/components/Header.vue

@@ -45,6 +45,4 @@ defineProps({
     flex-direction: row;
   }
 }
-
-
 </style>

+ 0 - 156
src/views/ai/chat/components/MessageList.vue

@@ -1,156 +0,0 @@
-
-<template>
-  <div class="message-container" ref="messageContainer">
-    <div class="chat-list" v-for="(item, index) in list" :key="index">
-      <!--  靠左 message  -->
-      <div class="left-message message-item" v-if="item.type === 'system'">
-        <div class="avatar" >
-          <el-avatar
-            src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
-          />
-        </div>
-        <div class="message">
-          <div>
-            <el-text class="time">{{formatDate(item.createTime)}}</el-text>
-          </div>
-          <div class="left-text-container">
-            <el-text class="left-text">
-              {{item.content}}
-            </el-text>
-          </div>
-          <div class="left-btns">
-            <div class="btn-cus" @click="noCopy(item.content)">
-              <img class="btn-image" src="@/assets/ai/copy.svg"/>
-              <el-text class="btn-cus-text">复制</el-text>
-            </div>
-            <div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)">
-              <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/>
-              <el-text class="btn-cus-text">删除</el-text>
-            </div>
-          </div>
-        </div>
-      </div>
-      <!--  靠右 message  -->
-      <div class="right-message message-item" v-if="item.type === 'user'">
-        <div class="avatar">
-          <el-avatar
-            src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
-          />
-        </div>
-        <div class="message">
-          <div>
-            <el-text class="time">{{formatDate(item.createTime)}}</el-text>
-          </div>
-          <div class="right-text-container">
-            <el-text class="right-text">
-              {{item.content}}
-            </el-text>
-          </div>
-          <div class="right-btns">
-            <div class="btn-cus"  @click="noCopy(item.content)">
-              <img class="btn-image" src="@/assets/ai/copy.svg"/>
-              <el-text class="btn-cus-text">复制</el-text>
-            </div>
-            <div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)">
-              <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/>
-              <el-text class="btn-cus-text">删除</el-text>
-            </div>
-          </div>
-        </div>
-
-      </div>
-    </div>
-  </div>
-</template>
-<script setup lang="ts">
-
-import { useClipboard } from '@vueuse/core'
-import { ChatMessageApi, ChatMessageVO, ChatMessageSendVO} from '@/api/ai/chat/message'
-import { formatDate } from '@/utils/formatTime'
-
-// 初始化 copy 到粘贴板
-const { copy, isSupported } = useClipboard();
-/** chat message 列表 */
-defineOptions({ name: 'chatMessageList' })
-const list = ref<ChatMessageVO[]>([]) // 列表的数据
-
-// 对话id TODO @范 先写死
-const conversationId = '1781604279872581648'
-const content = '苹果是什么颜色?'
-
-/** 查询列表 */
-const messageList = async () => {
-  try {
-    // 获取列表数据
-    const res = await ChatMessageApi.messageList(conversationId)
-    list.value = res;
-    // 滚动到最下面
-    scrollToBottom();
-  } finally {
-  }
-}
-// ref
-const messageContainer: any = ref(null);
-const isScrolling = ref(false)//用于判断用户是否在滚动
-
-function scrollToBottom() {
-  nextTick(() => {
-    //注意要使用nexttick以免获取不到dom
-    console.log('isScrolling.value', isScrolling.value)
-    if (!isScrolling.value) {
-      messageContainer.value.scrollTop = messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
-    }
-  })
-}
-
-function handleScroll() {
-  const scrollContainer = messageContainer.value
-  const scrollTop = scrollContainer.scrollTop
-  const scrollHeight = scrollContainer.scrollHeight
-  const offsetHeight = scrollContainer.offsetHeight
-
-  if (scrollTop + offsetHeight < scrollHeight) {
-    // 用户开始滚动并在最底部之上,取消保持在最底部的效果
-    isScrolling.value = true
-  } else {
-    // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
-    isScrolling.value = false
-  }
-}
-
-function noCopy(content) {
-  copy(content)
-  ElMessage({
-    message: '复制成功!',
-    type: 'success',
-  })
-}
-
-const onDelete = async (id) => {
-  // 删除 message
-  await ChatMessageApi.delete(id)
-  ElMessage({
-    message: '删除成功!',
-    type: 'success',
-  })
-  // 重新获取 message 列表
-  await messageList();
-}
-
-/** 初始化 **/
-onMounted(async () => {
-  // 获取列表数据
-  messageList();
-
-  // scrollToBottom();
-  // await nextTick
-  // 监听滚动事件,判断用户滚动状态
-  messageContainer.value.addEventListener('scroll', handleScroll)
-
-})
-</script>
-
-<style scoped lang="scss">
-
-
-</style>

File diff suppressed because it is too large
+ 438 - 465
src/views/ai/chat/index.vue


+ 163 - 0
src/views/ai/chat/manager/ChatConversationList.vue

@@ -0,0 +1,163 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="聊天编号" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入聊天编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="对话编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column label="对话标题" align="center" prop="title" width="180" fixed="left" />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="角色" align="center" prop="roleName" width="180" />
+      <el-table-column label="模型标识" align="center" prop="model" width="180" />
+      <el-table-column label="消息数" align="center" prop="messageCount" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="温度参数" align="center" prop="temperature" />
+      <el-table-column label="回复 Token 数" align="center" prop="maxTokens" width="120" />
+      <el-table-column label="上下文数量" align="center" prop="maxContexts" width="120" />
+      <el-table-column label="操作" align="center" width="180" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:chat-conversation:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import * as UserApi from '@/api/system/user'
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ChatConversationVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined,
+  title: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ChatConversationApi.getChatConversationPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ChatConversationApi.deleteChatConversationByAdmin(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 175 - 0
src/views/ai/chat/manager/ChatMessageList.vue

@@ -0,0 +1,175 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="对话编号" prop="conversationId">
+        <el-input
+          v-model="queryParams.conversationId"
+          placeholder="请输入对话编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="消息编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column
+        label="对话编号"
+        align="center"
+        prop="conversationId"
+        width="180"
+        fixed="left"
+      />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="角色" align="center" prop="roleName" width="180" />
+      <el-table-column label="消息类型" align="center" prop="type" width="100" />
+      <el-table-column label="模型标识" align="center" prop="model" width="180" />
+      <el-table-column label="消息内容" align="center" prop="content" width="300" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="回复消息编号" align="center" prop="replyId" width="180" />
+      <el-table-column label="携带上下文" align="center" prop="useContext" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.useContext" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:chat-message:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
+import * as UserApi from '@/api/system/user'
+import { DICT_TYPE } from '@/utils/dict'
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ChatMessageVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  conversationId: undefined,
+  userId: undefined,
+  content: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ChatMessageApi.getChatMessagePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ChatMessageApi.deleteChatMessageByAdmin(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 20 - 0
src/views/ai/chat/manager/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <ContentWrap>
+    <el-tabs>
+      <el-tab-pane label="对话列表">
+        <ChatConversationList />
+      </el-tab-pane>
+      <el-tab-pane label="消息列表">
+        <ChatMessageList />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import ChatConversationList from './ChatConversationList.vue'
+import ChatMessageList from './ChatMessageList.vue'
+
+/** AI 聊天对话 列表 */
+defineOptions({ name: 'AiChatManager' })
+</script>

+ 6 - 5
src/views/ai/chat/role/RoleCategoryList.vue

@@ -1,8 +1,8 @@
 <template>
   <div class="category-list">
-    <div class="category" v-for="category in categoryList" :key="category">
-      <el-button plain v-if="category !== active" @click="handleCategoryClick(category)">{{ category }}</el-button>
-      <el-button plain type="primary" v-else @click="handleCategoryClick(category)">{{ category }}</el-button>
+    <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>
   </div>
 </template>
@@ -17,7 +17,8 @@ defineProps({
   },
   active: {
     type: String,
-    required: false
+    required: false,
+    default: '全部'
   }
 })
 
@@ -40,7 +41,7 @@ const handleCategoryClick = async (category) => {
   .category {
     display: flex;
     flex-direction: row;
-    margin-right: 20px;
+    margin-right: 10px;
   }
 }
 </style>

+ 73 - 40
src/views/ai/chat/role/RoleList.vue

@@ -1,59 +1,72 @@
 <template>
-  <div class="card-list">
-    <el-card class="card" body-class="card-body" v-for="role in roleList" :key="role.id">
-      <!--  更多 -->
-      <div class="more-container">
-        <el-dropdown @command="handleMoreClick">
+  <div class="card-list" ref="tabsRef"  @scroll="handleTabsScroll">
+    <div class="card-item" v-for="role in roleList" :key="role.id">
+      <el-card class="card" body-class="card-body">
+        <!--  更多 -->
+        <div class="more-container" v-if="showMore">
+          <el-dropdown @command="handleMoreClick">
           <span class="el-dropdown-link">
              <el-button type="text" >
                 <el-icon><More /></el-icon>
               </el-button>
           </span>
-          <template #dropdown>
-            <el-dropdown-menu>
-              <el-dropdown-item :command="['edit', role]" >
-                <el-icon><EditPen /></el-icon>编辑
-              </el-dropdown-item>
-              <el-dropdown-item :command="['delete', role]"  style="color: red;" >
-                <el-icon><Delete /></el-icon>
-                <span>删除</span>
-              </el-dropdown-item>
-            </el-dropdown-menu>
-          </template>
-        </el-dropdown>
-      </div>
-      <!--  头像 -->
-      <div>
-        <img class="avatar" :src="role.avatar"/>
-      </div>
-      <div class="right-container">
-        <div class="content-container">
-          <div class="title">{{ role.name }}</div>
-          <div class="description">{{ role.description }}</div>
-
+            <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item :command="['edit', role]" >
+                  <el-icon><EditPen /></el-icon>编辑
+                </el-dropdown-item>
+                <el-dropdown-item :command="['delete', role]"  style="color: red;" >
+                  <el-icon><Delete /></el-icon>
+                  <span>删除</span>
+                </el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
+        <!--  头像 -->
+        <div>
+          <img class="avatar" :src="role.avatar"/>
         </div>
-        <div class="btn-container">
-          <el-button type="primary" size="small" @click="handleUseClick(role)">使用</el-button>
+        <div class="right-container">
+          <div class="content-container">
+            <div class="title">{{ role.name }}</div>
+            <div class="description">{{ role.description }}</div>
+          </div>
+          <div class="btn-container">
+            <el-button type="primary" size="small" @click="handleUseClick(role)">使用</el-button>
+          </div>
         </div>
-      </div>
-    </el-card>
+      </el-card>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
 import {ChatRoleVO} from '@/api/ai/model/chatRole'
-import {PropType} from "vue";
+import {PropType, ref} from "vue";
 import {Delete, EditPen, More} from "@element-plus/icons-vue";
 
+const tabsRef = ref<any>() // tabs ref
+
 // 定义属性
 const props = defineProps({
+  loading: {
+    type: Boolean,
+    required: true
+  },
   roleList: {
     type: Array as PropType<ChatRoleVO[]>,
     required: true
+  },
+  showMore: {
+    type: Boolean,
+    required: false,
+    default: false
   }
 })
 // 定义钩子
-const emits = defineEmits(['onDelete', 'onEdit', 'onUse'])
+const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
 
 // more 点击
 const handleMoreClick = async (data) => {
@@ -71,6 +84,19 @@ const handleUseClick = (role) => {
   emits('onUse', role)
 }
 
+const handleTabsScroll = async () => {
+  if (tabsRef.value) {
+    const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
+    console.log('scrollTop', scrollTop)
+    if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
+      console.log('分页')
+      // page.value++;
+      // fetchData(page.value);
+      await emits('onPage')
+    }
+  }
+}
+
 onMounted(() => {
   console.log('props', props.roleList)
 })
@@ -80,9 +106,9 @@ onMounted(() => {
 <style lang="scss">
 // 重写 card 组件 body 样式
 .card-body {
-  max-width: 300px;
-  width: 300px;
-  padding: 15px;
+  max-width: 240px;
+  width: 240px;
+  padding: 15px 15px 10px 15px;
 
   display: flex;
   flex-direction: row;
@@ -99,17 +125,25 @@ onMounted(() => {
   flex-direction: row;
   flex-wrap: wrap;
   position: relative;
+  height: 100%;
+  overflow: auto;
+  padding: 0px 25px;
+  padding-bottom: 140px;
+  align-items: start;
+  align-content: flex-start;
+  justify-content: start;
 
   .card {
+    display: inline-block;
     margin-right: 20px;
     border-radius: 10px;
-    margin-bottom: 30px;
+    margin-bottom: 20px;
     position: relative;
 
     .more-container {
       position: absolute;
+      top: 0;
       right: 12px;
-      top: 0px;
     }
 
     .avatar {
@@ -126,7 +160,6 @@ onMounted(() => {
 
       .content-container {
         height: 85px;
-        overflow: hidden;
 
         .title {
           font-size: 18px;
@@ -144,7 +177,7 @@ onMounted(() => {
       .btn-container {
         display: flex;
         flex-direction: row-reverse;
-        margin-top: 15px;
+        margin-top: 2px;
       }
     }
 

+ 149 - 72
src/views/ai/chat/role/index.vue

@@ -1,13 +1,15 @@
 <!-- chat 角色仓库 -->
 <template>
   <el-container class="role-container">
-    <ChatRoleForm  ref="formRef" @success="handlerAddRoleSuccess"  />
-
-    <Header title="角色仓库"/>
+    <ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
+    <!--  header  -->
+    <Header title="角色仓库" style="position: relative" />
+    <!--  main  -->
     <el-main class="role-main">
       <div class="search-container">
         <!-- 搜索按钮 -->
         <el-input
+          :loading="loading"
           v-model="search"
           class="search-input"
           size="default"
@@ -15,119 +17,161 @@
           :suffix-icon="Search"
           @change="getActiveTabsRole"
         />
-        <el-button type="primary" @click="handlerAddRole" style="margin-left: 20px;">
-          <el-icon><User /></el-icon>
+        <el-button
+          v-if="activeRole == 'my-role'"
+          type="primary"
+          @click="handlerAddRole"
+          style="margin-left: 20px"
+        >
+          <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
+          <el-icon>
+            <User />
+          </el-icon>
           添加角色
         </el-button>
       </div>
       <!-- tabs -->
       <el-tabs v-model="activeRole" class="tabs" @tab-click="handleTabsClick">
         <el-tab-pane class="role-pane" label="我的角色" name="my-role">
-          <RoleList :role-list="myRoleList" @onDelete="handlerCardDelete" @onEdit="handlerCardEdit" @onUse="handlerCardUse" style="margin-top: 20px;" />
+          <RoleList
+            :loading="loading"
+            :role-list="myRoleList"
+            :show-more="true"
+            @on-delete="handlerCardDelete"
+            @on-edit="handlerCardEdit"
+            @on-use="handlerCardUse"
+            @on-page="handlerCardPage('my')"
+            style="margin-top: 20px"
+          />
         </el-tab-pane>
         <el-tab-pane label="公共角色" name="public-role">
-          <RoleCategoryList :category-list="categoryList" :active="activeCategory" @onCategoryClick="handlerCategoryClick" />
-          <RoleList :role-list="publicRoleList" @onDelete="handlerCardDelete" @onEdit="handlerCardEdit" style="margin-top: 20px;" />
+          <RoleCategoryList
+            class="role-category-list"
+            :category-list="categoryList"
+            :active="activeCategory"
+            @on-category-click="handlerCategoryClick"
+          />
+          <RoleList
+            :role-list="publicRoleList"
+            @on-delete="handlerCardDelete"
+            @on-edit="handlerCardEdit"
+            @on-use="handlerCardUse"
+            @on-page="handlerCardPage('public')"
+            style="margin-top: 20px"
+            loading
+          />
         </el-tab-pane>
       </el-tabs>
     </el-main>
   </el-container>
-
 </template>
 
-<!--  setup  -->
 <script setup lang="ts">
-import {ref} from "vue";
+import { ref } from 'vue'
 import Header from '@/views/ai/chat/components/Header.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 { TabsPaneContext } from 'element-plus'
+import { Search, User } from '@element-plus/icons-vue'
 
-// 获取路由
-const router = useRouter()
+const router = useRouter() // 路由对象
 
 // 属性定义
-const activeRole = ref<string>('my-role') // 选中的角色
-const loadding = ref<boolean>(true) // 加载中
+const loading = ref<boolean>(false) // 加载中
+const activeRole = ref<string>('my-role') // 选中的角色 TODO @fan:是不是叫 activeTab 会更明确一点哈。选中的角色,会以为是某个角色
 const search = ref<string>('') // 加载中
+// TODO @fan:要不 myPage、pubPage,搞成类似 const queryParams = reactive({ ,分别搞成两个大的参数哈?
 const myPageNo = ref<number>(1) // my 分页下标
 const myPageSize = ref<number>(50) // my 分页大小
 const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小
 const publicPageNo = ref<number>(1) // public 分页下标
 const publicPageSize = ref<number>(50) // public 分页大小
 const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小
-const activeCategory = ref<string>('') // 选择中的分类
+const activeCategory = ref<string>('全部') // 选择中的分类
 const categoryList = ref<string[]>([]) // 角色分类类别
-/** 添加/修改操作 */
-const formRef = ref()
-// tabs 点击
+
+/** tabs 点击 */
 const handleTabsClick = async (tab: TabsPaneContext) => {
   // 设置切换状态
-  const activeTabs = tab.paneName + ''
-  activeRole.value = activeTabs;
+  activeRole.value = tab.paneName + ''
   // 切换的时候重新加载数据
   await getActiveTabsRole()
 }
 
-// 获取 my role
-const getMyRole = async () => {
-  const params:ChatRolePageReqVO = {
+/** 获取 my role 我的角色 */
+const getMyRole = async (append?: boolean) => {
+  const params: ChatRolePageReqVO = {
     pageNo: myPageNo.value,
     pageSize: myPageSize.value,
-    category: activeCategory.value,
     name: search.value,
     publicStatus: false
   }
   const { total, list } = await ChatRoleApi.getMyPage(params)
-  myRoleList.value = list
+  if (append) {
+    myRoleList.value.push.apply(myRoleList.value, list)
+  } else {
+    myRoleList.value = list
+  }
 }
 
-// 获取 public role
-const getPublicRole = async () => {
-  const params:ChatRolePageReqVO = {
+/** 获取 public role 公共角色 */
+const getPublicRole = async (append?: boolean) => {
+  const params: ChatRolePageReqVO = {
     pageNo: publicPageNo.value,
     pageSize: publicPageSize.value,
-    category: activeCategory.value,
+    category: activeCategory.value === '全部' ? '' : activeCategory.value,
     name: search.value,
     publicStatus: true
   }
   const { total, list } = await ChatRoleApi.getMyPage(params)
-  publicRoleList.value = list
+  if (append) {
+    publicRoleList.value.push.apply(publicRoleList.value, list)
+  } else {
+    publicRoleList.value = list
+  }
 }
 
-// 获取选中的 tabs 角色
+/** 获取选中的 tabs 角色 */
 const getActiveTabsRole = async () => {
   if (activeRole.value === 'my-role') {
+    myPageNo.value = 1
     await getMyRole()
   } else {
+    publicPageNo.value = 1
     await getPublicRole()
   }
 }
 
-// 获取角色分类列表
+/** 获取角色分类列表 */
 const getRoleCategoryList = async () => {
-  categoryList.value = await ChatRoleApi.getCategoryList()
+  const res = await ChatRoleApi.getCategoryList()
+  const defaultRole = ['全部']
+  categoryList.value = [...defaultRole, ...res]
 }
 
-// 处理分类点击
+/** 处理分类点击 */
 const handlerCategoryClick = async (category: string) => {
-  if (activeCategory.value === category) {
-    activeCategory.value = ''
-  } else {
-    activeCategory.value = category
-  }
+  // 切换选择的分类
+  activeCategory.value = category
+  // 筛选
   await getActiveTabsRole()
 }
 
-// 添加角色
+/** 添加/修改操作 */
+const formRef = ref()
 const handlerAddRole = async () => {
   formRef.value.open('my-create', null, '添加角色')
 }
 
+/** 添加角色成功 */
+const handlerAddRoleSuccess = async (e) => {
+  // 刷新数据
+  await getActiveTabsRole()
+}
+
 // card 删除
 const handlerCardDelete = async (role) => {
   await ChatRoleApi.deleteMy(role.id)
@@ -140,64 +184,91 @@ const handlerCardEdit = async (role) => {
   formRef.value.open('my-update', role.id, '编辑角色')
 }
 
-// card 使用
+/** card 分页:获取下一页 */
+const handlerCardPage = async (type) => {
+  console.log('handlerCardPage', type)
+  try {
+    loading.value = true
+    if (type === 'public') {
+      publicPageNo.value++
+      await getPublicRole(true)
+    } else {
+      myPageNo.value++
+      await getMyRole(true)
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 选择 card 角色:新建聊天对话 */
 const handlerCardUse = async (role) => {
-  const data : ChatConversationVO = {
+  // 1. 创建对话
+  const data: ChatConversationVO = {
     roleId: role.id
   } as unknown as ChatConversationVO
-  // 创建对话
-  const conversation = await ChatConversationApi.createChatConversationMy(data)
-  // 调整页面
-  router.push({
-    path: `/ai/chat/index`,
+  const conversationId = await ChatConversationApi.createChatConversationMy(data)
+  // 2. 跳转页面
+  // TODO @fan:最好用 name,后续可能会改~~~
+  await router.push({
+    path: `/ai/chat`,
     query: {
-      conversationId: conversation,
+      conversationId: conversationId
     }
   })
 }
 
-// 添加角色成功
-const handlerAddRoleSuccess = async  (e) => {
-  console.log(e)
-  // 刷新数据
-  await getActiveTabsRole()
-}
-
-//
-onMounted( async () => {
+/** 初始化 **/
+onMounted(async () => {
   // 获取分类
   await getRoleCategoryList()
   // 获取 role 数据
   await getActiveTabsRole()
 })
+// TODO @fan:css 是不是可以融合到 scss 里面呀?
 </script>
+<style lang="css">
+.el-tabs__content {
+  position: relative;
+  height: 100%;
+  overflow: hidden;
+}
+
+.el-tabs__nav-scroll {
+  margin: 10px 20px;
+}
+</style>
 <!-- 样式 -->
 <style scoped lang="scss">
-
 // 跟容器
 .role-container {
   position: absolute;
-  margin: 0;
-  padding: 0;
   width: 100%;
   height: 100%;
+  margin: 0;
+  padding: 0;
   left: 0;
   right: 0;
   top: 0;
   bottom: 0;
   background-color: #ffffff;
-
+  overflow: hidden;
   display: flex;
   flex-direction: column;
 
   .role-main {
+    flex: 1;
+    overflow: hidden;
+    margin: 0;
+    padding: 0;
     position: relative;
 
     .search-container {
-      //position: absolute;
-      //right: 20px;
-      //top: 10px;
-      //z-index: 100;
+      margin: 20px 20px 0px 20px;
+      position: absolute;
+      right: 0;
+      top: -5px;
+      z-index: 100;
     }
 
     .search-input {
@@ -206,14 +277,20 @@ onMounted( async () => {
 
     .tabs {
       position: relative;
+      height: 100%;
+
+      .role-category-list {
+        margin: 0 27px;
+      }
     }
 
     .role-pane {
       display: flex;
       flex-direction: column;
+      height: 100%;
+      overflow-y: auto;
+      position: relative;
     }
   }
 }
-
-
 </style>

+ 141 - 0
src/views/ai/image/ImageDetailDrawer.vue

@@ -0,0 +1,141 @@
+<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, ImageVO } from '@/api/ai/image'
+import ImageTaskCard from './ImageTaskCard.vue'
+
+const showDrawer = ref<boolean>(false) // 是否显示
+const imageDetail = ref<ImageVO>({} as ImageVO) // 图片详细信息
+
+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.getImageMy(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>

+ 244 - 0
src/views/ai/image/ImageTask.vue

@@ -0,0 +1,244 @@
+<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, ImageVO, 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<ImageVO[]>([]) // image 列表
+const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 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.getImagePageMy({
+      pageNo: pageNo.value,
+      pageSize: pageSize.value
+    })
+    if (apply) {
+      imageList.value = [...imageList.value, ...list]
+    } else {
+      imageList.value = list
+    }
+    pageTotal.value = total
+    // 需要 watch 的数据
+    const newWatImages = {}
+    imageList.value.forEach((item) => {
+      if (item.status === 10) {
+        newWatImages[item.id] = item
+      }
+    })
+    inProgressImageMap.value = newWatImages
+  } finally {
+    if (imageTaskLoadingInstance.value) {
+      imageTaskLoadingInstance.value.close()
+      imageTaskLoadingInstance.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 === 10) {
+      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
+}
+
+/**  图片 - btn click  */
+const handlerImageBtnClick = async (type: string, imageDetail: ImageVO) => {
+  // 获取 image detail id
+  showImageDetailId.value = imageDetail.id
+  // 处理不用 btn
+  if (type === 'more') {
+    await handlerDrawerOpen()
+  } else if (type === 'delete') {
+    await message.confirm(`是否删除照片?`)
+    await ImageApi.deleteImageMy(imageDetail.id)
+    await getImageList()
+    message.success('删除成功!')
+  } else if (type === 'download') {
+    await downloadImage(imageDetail.picUrl)
+  }
+}
+
+/**  图片 - mj btn click  */
+const handlerImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageVO) => {
+  // 1、构建 params 参数
+  const data = {
+    id: imageDetail.id,
+    customId: button.customId
+  } as ImageMjActionVO
+  // 2、发送 action
+  await ImageApi.midjourneyAction(data)
+  // 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 refreshWatchImages()
+  }, 1000 * 3)
+})
+
+/** 组件取消挂在的时候 */
+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>

+ 145 - 0
src/views/ai/image/ImageTaskCard.vue

@@ -0,0 +1,145 @@
+<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 { ImageVO, 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<ImageVO>,
+    require: true
+  }
+})
+
+/**  按钮 - 点击事件  */
+const handlerBtnClick = async (type, imageDetail: ImageVO) => {
+  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>

+ 398 - 0
src/views/ai/image/dall3/index.vue

@@ -0,0 +1,398 @@
+<!-- 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>

+ 105 - 0
src/views/ai/image/index.vue

@@ -0,0 +1,105 @@
+<!-- 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'" @on-draw-complete="handlerDrawComplete" />
+      </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>

+ 250 - 0
src/views/ai/image/manager/index.vue

@@ -0,0 +1,250 @@
+<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 !== 20"
+          />
+        </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'
+
+/** 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>

+ 384 - 0
src/views/ai/image/midjourney/index.vue

@@ -0,0 +1,384 @@
+<!-- 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
+
+/**  热词 - 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>

+ 405 - 0
src/views/ai/image/stable-diffusion/index.vue

@@ -0,0 +1,405 @@
+<!-- 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="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="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">CLIP</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="selectClipGuidancePreset" placeholder="Select" size="large" style="width: 350px">
+        <el-option v-for="item in clipGuidancePresets" :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="selectStylePreset" placeholder="Select" size="large" style="width: 350px">
+        <el-option v-for="item in stylePresets" :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="imageWidth" style="width: 170px" placeholder="图片宽度" />
+      <el-input v-model="imageHeight" style="width: 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"
+        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="scale"
+        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="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
+  </div>
+</template>
+<script setup lang="ts">
+import {ImageApi, ImageDrawReqVO} from '@/api/ai/image'
+
+// image 模型
+interface ImageModelVO {
+  key: string
+  name: string
+}
+
+// 定义属性
+const prompt = ref<string>('') // 提示词
+const drawIn = ref<boolean>(false) // 生成中
+const selectHotWord = ref<string>('') // 选中的热词
+const imageWidth = ref<number>(512) // 图片宽度
+const imageHeight = ref<number>(512) // 图片高度
+
+const hotWords = ref<string[]>([
+  '中国旗袍',
+  '古装美女',
+  '卡通头像',
+  '机甲战士',
+  '童话小屋',
+  '中国长城'
+]) // 热词
+// message
+const message = useMessage()
+
+// 采样方法 TODO @fan:有 Euler a;DPM++ 2S a;DPM++ 2M;DPM++ SDE;DPM++ 2M SDE;UniPC;Restart;另外,要不这种枚举,我们都放到 image 里?写成 stableDiffusionSampler ?
+const selectSampler = ref<any>({}) // 模型
+// DDIM DDPM K_DPMPP_2M K_DPMPP_2S_ANCESTRAL K_DPM_2 K_DPM_2_ANCESTRAL K_EULER K_EULER_ANCESTRAL K_HEUN K_LMS
+const sampler = ref<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'
+  },
+])
+selectSampler.value = sampler.value[0]
+
+// 风格
+// 3d-model analog-film anime cinematic comic-book digital-art enhance fantasy-art isometric
+// line-art low-poly modeling-compound neon-punk origami photographic pixel-art tile-texture
+const selectStylePreset = ref<any>({}) // 模型
+const stylePresets = ref<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'
+  },
+])
+selectStylePreset.value = stylePresets.value[0]
+
+
+// 文本提示相匹配的图像(clip_guidance_preset) 简称 CLIP
+// https://platform.stability.ai/docs/api-reference#tag/SDXL-and-SD1.6/operation/textToImage
+// FAST_BLUE FAST_GREEN NONE SIMPLE SLOW SLOWER SLOWEST
+const selectClipGuidancePreset = ref<any>({}) // 模型
+const clipGuidancePresets = ref<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'
+  },
+])
+selectClipGuidancePreset.value = clipGuidancePresets.value[0]
+
+const steps = ref<number>(20) // 迭代步数
+const seed = ref<number>(42) // 控制生成图像的随机性
+const scale = ref<number>(7.5) // 引导系数
+
+// 定义 Props
+const props = defineProps({})
+// 定义 emits
+const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
+
+/** 热词 - click  */
+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', 'StableDiffusion')
+    // 发送请求
+    const form = {
+      platform: 'StableDiffusion',
+      model: 'stable-diffusion-v1-6',
+      prompt: prompt.value, // 提示词
+      width: imageWidth.value, // 图片宽度
+      height: imageHeight.value, // 图片高度
+      options: {
+        seed: seed.value, // 随机种子
+        steps: steps.value, // 图片生成步数
+        scale: scale.value, // 引导系数
+        sampler: selectSampler.value.key, // 采样算法
+        clipGuidancePreset: selectClipGuidancePreset.value.key, // 文本提示相匹配的图像 CLIP
+        stylePreset: selectStylePreset.value.key, // 风格
+      }
+    } 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>

+ 19 - 3
src/views/ai/model/chatModel/ChatModelForm.vue

@@ -48,13 +48,29 @@
         </el-radio-group>
       </el-form-item>
       <el-form-item label="温度参数" prop="temperature">
-        <el-input v-model="formData.temperature" placeholder="请输入温度参数" />
+        <el-input-number
+          v-model="formData.temperature"
+          placeholder="请输入温度参数"
+          :min="0"
+          :max="2"
+          :precision="2"
+        />
       </el-form-item>
       <el-form-item label="回复数 Token 数" prop="maxTokens">
-        <el-input v-model="formData.maxTokens" placeholder="请输入回复数 Token 数" />
+        <el-input-number
+          v-model="formData.maxTokens"
+          placeholder="请输入回复数 Token 数"
+          :min="0"
+          :max="4096"
+        />
       </el-form-item>
       <el-form-item label="上下文数量" prop="maxContexts">
-        <el-input v-model="formData.maxContexts" placeholder="请输入上下文数量" />
+        <el-input-number
+          v-model="formData.maxContexts"
+          placeholder="请输入上下文数量"
+          :min="0"
+          :max="20"
+        />
       </el-form-item>
     </el-form>
     <template #footer>

+ 27 - 42
src/views/ai/model/chatRole/ChatRoleForm.vue

@@ -8,10 +8,10 @@
       v-loading="formLoading"
     >
       <el-form-item label="角色名称" prop="name">
-        <el-input v-model="formData.name" placeholder="请输入角色名称"/>
+        <el-input v-model="formData.name" placeholder="请输入角色名称" />
       </el-form-item>
       <el-form-item label="角色头像" prop="avatar">
-        <UploadImg v-model="formData.avatar" height="60px" width="60px"/>
+        <UploadImg v-model="formData.avatar" height="60px" width="60px" />
       </el-form-item>
       <el-form-item label="绑定模型" prop="modelId" v-if="!isUser(formType)">
         <el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
@@ -24,17 +24,14 @@
         </el-select>
       </el-form-item>
       <el-form-item label="角色类别" prop="category" v-if="!isUser(formType)">
-        <el-input v-model="formData.category" placeholder="请输入角色类别"/>
+        <el-input v-model="formData.category" placeholder="请输入角色类别" />
       </el-form-item>
       <el-form-item label="角色描述" prop="description">
-        <el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述"/>
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述" />
       </el-form-item>
       <el-form-item label="角色设定" prop="systemMessage">
         <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
       </el-form-item>
-      <el-form-item label="欢迎语👏🏻" prop="welcomeMessage" v-if="isUser(formType)">
-        <el-input type="textarea" v-model="formData.welcomeMessage" placeholder="请输入欢迎语"/>
-      </el-form-item>
       <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser(formType)">
         <el-radio-group v-model="formData.publicStatus">
           <el-radio
@@ -47,7 +44,7 @@
         </el-radio-group>
       </el-form-item>
       <el-form-item label="角色排序" prop="sort" v-if="!isUser(formType)">
-        <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1"/>
+        <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
       </el-form-item>
       <el-form-item label="开启状态" prop="status" v-if="!isUser(formType)">
         <el-radio-group v-model="formData.status">
@@ -68,15 +65,15 @@
   </Dialog>
 </template>
 <script setup lang="ts">
-import {getIntDictOptions, getBoolDictOptions, DICT_TYPE} from '@/utils/dict'
-import {ChatRoleApi, ChatRoleVO} from '@/api/ai/model/chatRole'
-import {CommonStatusEnum} from '@/utils/constants'
-import {ChatModelApi, ChatModelVO} from '@/api/ai/model/chatModel'
+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'
 
 /** AI 聊天角色 表单 */
-defineOptions({name: 'ChatRoleForm'})
+defineOptions({ name: 'ChatRoleForm' })
 
-const {t} = useI18n() // 国际化
+const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 
 const dialogVisible = ref(false) // 弹窗的是否展示
@@ -92,44 +89,36 @@ const formData = ref({
   sort: undefined,
   description: undefined,
   systemMessage: undefined,
-  welcomeMessage: undefined,
   publicStatus: true,
   status: CommonStatusEnum.ENABLE
 })
-
-// 是否
-const isUser = (type: string) => {
-  return (type === 'my-create' || type === 'my-update')
-}
-
 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'
+}
+
+// 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'}],
-    // welcomeMessage: [{ required: true, message: '欢迎语不能为空', trigger: 'blur' }],
-    publicStatus: [{required: true, message: '是否公开不能为空', trigger: 'blur'}]
-  }
-
-  if (isUser(type)) {
-    formRulesObj['welcomeMessage'] = [{
-      required: true,
-      message: '欢迎语不能为空',
-      trigger: 'blur'
-    }]
+    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)
 }
 
 /** 打开弹窗 */
+// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
 const open = async (type: string, id?: number, title?: string) => {
   dialogVisible.value = true
   dialogTitle.value = title || t('action.' + type)
@@ -148,7 +137,7 @@ const open = async (type: string, id?: number, title?: string) => {
   // 获得下拉数据
   chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE)
 }
-defineExpose({open}) // 提供 open 方法,用于打开弹窗
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 /** 提交表单 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
@@ -159,10 +148,8 @@ const submitForm = async () => {
   formLoading.value = true
   try {
     const data = formData.value as unknown as ChatRoleVO
-
     // tip: my-create、my-update 是 chat 角色仓库调用
     // tip: create、else 是后台管理调用
-
     if (formType.value === 'my-create') {
       await ChatRoleApi.createMy(data)
       message.success(t('common.createSuccess'))
@@ -184,7 +171,6 @@ const submitForm = async () => {
   }
 }
 
-
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
@@ -196,7 +182,6 @@ const resetForm = () => {
     sort: undefined,
     description: undefined,
     systemMessage: undefined,
-    welcomeMessage: undefined,
     publicStatus: true,
     status: CommonStatusEnum.ENABLE
   }

+ 21 - 0
src/views/ai/music/components/index.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="flex h-1/1">
+    <!-- 模式 -->
+    <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<{generateMusic: (...args) => void} | null>(null)
+
+function generateMusic (args: {formData: Recordable}) {
+ unref(listRef)?.generateMusic(args.formData)
+}
+</script>

+ 9 - 0
src/views/ai/music/components/list/audioBar/index.vue

@@ -0,0 +1,9 @@
+<template>
+  <div class="h-72px bg-[var(--el-bg-color-overlay)] b-solid b-1 b-[var(--el-border-color)] b-l-none">播放器</div>
+</template>
+
+<script lang="ts" setup>
+
+defineOptions({ name: 'Index' })
+
+</script>

+ 94 - 0
src/views/ai/music/components/list/index.vue

@@ -0,0 +1,94 @@
+<template>
+  <div class="flex flex-col h-full">
+    <div class="flex-auto flex overflow-hidden">
+      <el-tabs v-model="currentType" class="flex-auto px-[var(--app-content-padding)]">
+        <!-- 我的创作 -->
+        <el-tab-pane label="我的创作" v-loading="loading" name="mine">
+          <el-row v-if="mySongList.length" :gutter="12">
+            <el-col v-for="song in mySongList" :key="song.id" :span="24">
+              <songCard v-bind="song"/>
+            </el-col>
+          </el-row>
+          <el-empty v-else description="暂无音乐"/>
+        </el-tab-pane>
+
+        <!-- 试听广场 -->
+        <el-tab-pane label="试听广场" v-loading="loading" 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 v-bind="song"/>
+            </el-col>
+          </el-row>
+          <el-empty v-else description="暂无音乐"/>
+        </el-tab-pane>
+      </el-tabs>
+      <!-- songInfo -->
+      <songInfo v-bind="squareSongList[0]" 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 mySongList = ref<Recordable[]>([])
+const squareSongList = ref<Recordable[]>([])
+
+/*
+ *@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: '我走后',
+        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)
+}
+
+defineExpose({
+  generateMusic
+})
+</script>
+
+
+<style lang="scss" scoped>
+:deep(.el-tabs) {
+  display: flex;
+  flex-direction: column;
+  .el-tabs__content{
+  padding: 0 7px;
+  overflow: auto;
+ }
+}
+</style>

+ 29 - 0
src/views/ai/music/components/list/songCard/index.vue

@@ -0,0 +1,29 @@
+<template>
+  <div class="flex bg-[var(--el-bg-color-overlay)] p-12px mb-12px rounded-1">
+    <el-image :src="imageUrl" class="flex-none w-80px"/>
+    <div class="ml-8px">
+      <div>{{ title }}</div>
+      <div class="mt-8px text-12px text-[var(--el-text-color-secondary)] line-clamp-2">
+        {{ desc }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+
+defineOptions({ name: 'Index' })
+
+defineProps({
+  imageUrl: {
+    type: String
+  },
+  title: {
+    type: String
+  },
+  desc: {
+    type: String
+  }
+})
+
+</script>

+ 33 - 0
src/views/ai/music/components/list/songInfo/index.vue

@@ -0,0 +1,33 @@
+<template>
+  <ContentWrap class="w-300px mb-[0!important] line-height-24px">
+    <el-image :src="imageUrl"/>
+    <div class="">{{ title }}</div>
+    <div class="text-[var(--el-text-color-secondary)] text-12px line-clamp-1">{{ desc }}</div>
+    <div class="text-[var(--el-text-color-secondary)] text-12px">{{ date }}</div>
+    <el-button size="small" round class="my-6px">信息复用</el-button>
+    <div class="text-[var(--el-text-color-secondary)] text-12px" v-html="lyric"></div>
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+
+defineOptions({ name: 'Index' })
+
+defineProps({
+  imageUrl: {
+    type: String
+  },
+  title: {
+    type: String
+  },
+  desc: {
+    type: String
+  },
+  date: {
+    type: String
+  },
+  lyric: {
+    type: String
+  }
+})
+</script>

+ 55 - 0
src/views/ai/music/components/mode/desc.vue

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

+ 44 - 0
src/views/ai/music/components/mode/index.vue

@@ -0,0 +1,44 @@
+<template>
+  <ContentWrap class="w-300px h-full">
+    <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')
+
+interface ModeRef {
+  formData: Recordable
+}
+const modeRef = ref<ModeRef | null>(null)
+
+/*
+ *@Description: 根据信息生成音乐
+ *@MethodAuthor: xiaohong
+ *@Date: 2024-06-27 16:40:16
+*/
+function generateMusic () {
+  emits('generate-music', {formData: unref(modeRef)?.formData.value})
+}
+</script>

+ 83 - 0
src/views/ai/music/components/mode/lyric.vue

@@ -0,0 +1,83 @@
+<template>
+  <div class="">
+    <Title title="歌词" desc="自己编写歌词或使用Ai生成歌词,两节/8行效果最佳">
+      <el-input
+        v-model="formData.lyric"
+        :autosize="{ minRows: 6, maxRows: 6}"
+        resize="none"
+        type="textarea"
+        maxlength="1200"
+        show-word-limit
+        placeholder="请输入您自己的歌词"
+      />
+    </Title>
+
+    <Title title="音乐风格">
+      <el-space class="flex-wrap">
+        <el-tag v-for="tag in tags" :key="tag" round class="mb-8px">{{tag}}</el-tag>
+      </el-space>
+
+      <el-button
+        :type="showCustom ? 'primary': 'default'" 
+        round 
+        size="small" 
+        class="mb-6px"
+        @click="showCustom = !showCustom"
+      >自定义风格
+      </el-button>
+    </Title>
+
+    <Title v-show="showCustom" desc="描述您想要的音乐风格,Suno无法识别艺术家的名字,但可以理解流派和氛围" class="-mt-12px">
+      <el-input
+        v-model="formData.style"
+        :autosize="{ minRows: 4, maxRows: 4}"
+        resize="none"
+        type="textarea"
+        maxlength="256"
+        show-word-limit
+        placeholder="输入音乐风格(英文)"
+      />
+    </Title>
+
+    <Title title="音乐/歌曲名称">
+      <el-input v-model="formData.name" placeholder="请输入音乐/歌曲名称"/>
+    </Title>
+
+    <Title title="版本">
+      <el-select v-model="formData.version" placeholder="请选择">
+        <el-option
+          v-for="item in [{
+            value: '3',
+            label: 'V3'
+          }, {
+            value: '2',
+            label: 'V2'
+          }]"
+          :key="item.value"
+          :label="item.label"
+          :value="item.value"
+        />
+      </el-select>
+    </Title>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Title from '../title/index.vue'
+defineOptions({ name: 'Lyric' })
+
+const tags = ['rock', 'punk', 'jazz', 'soul', 'country', 'kidsmusic', 'pop']
+
+const showCustom = ref(false)
+
+const formData = reactive({
+  lyric: '',
+  style: '',
+  name: '',
+  version: ''
+})
+
+defineExpose({
+  formData
+})
+</script>

+ 25 - 0
src/views/ai/music/components/title/index.vue

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

+ 285 - 0
src/views/ai/music/manager/index.vue

@@ -0,0 +1,285 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-input
+          v-model="queryParams.userId"
+          placeholder="请输入用户编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </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 !== 20"
+          />
+        </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'
+
+/** 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>

+ 1 - 5
src/views/infra/job/index.vue

@@ -235,11 +235,7 @@ const handleChangeStatus = async (row: JobApi.JobVO) => {
     message.success(text + '成功')
     // 刷新列表
     await getList()
-  } catch {
-    // 取消后,进行恢复按钮
-    row.status =
-      row.status === InfraJobStatusEnum.NORMAL ? InfraJobStatusEnum.STOP : InfraJobStatusEnum.NORMAL
-  }
+  } catch {}
 }
 
 /** 删除按钮操作 */

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