瀏覽代碼

!469 AI 大模型的草稿提交(code review 代码)
Merge pull request !469 from 芋道源码/dev

芋道源码 1 年之前
父節點
當前提交
b9178c88aa
共有 100 個文件被更改,包括 9534 次插入359 次删除
  1. 4 2
      package.json
  2. 20 4
      pnpm-lock.yaml
  3. 4 0
      src/App.vue
  4. 65 0
      src/api/ai/chat/conversation/index.ts
  5. 83 0
      src/api/ai/chat/message/index.ts
  6. 103 0
      src/api/ai/image/index.ts
  7. 44 0
      src/api/ai/model/apiKey/index.ts
  8. 53 0
      src/api/ai/model/chatModel/index.ts
  9. 80 0
      src/api/ai/model/chatRole/index.ts
  10. 41 0
      src/api/ai/music/index.ts
  11. 65 0
      src/api/ai/writer/index.ts
  12. 1 0
      src/api/infra/jobLog/index.ts
  13. 35 0
      src/api/mall/promotion/kefu/conversation/index.ts
  14. 36 0
      src/api/mall/promotion/kefu/message/index.ts
  15. 1 0
      src/assets/ai/copy-style2.svg
  16. 1 0
      src/assets/ai/copy.svg
  17. 二進制
      src/assets/ai/dall2.jpg
  18. 二進制
      src/assets/ai/dall3.jpg
  19. 1 0
      src/assets/ai/delete.svg
  20. 0 0
      src/assets/ai/gpt.svg
  21. 二進制
      src/assets/ai/qingxi.jpg
  22. 二進制
      src/assets/ai/ziran.jpg
  23. 4 5
      src/components/ContentWrap/src/ContentWrap.vue
  24. 113 8
      src/components/FormCreate/src/components/useApiSelect.tsx
  25. 51 17
      src/components/FormCreate/src/config/selectRule.ts
  26. 3 1
      src/components/FormCreate/src/config/useDictSelectRule.ts
  27. 3 1
      src/components/FormCreate/src/config/useSelectRule.ts
  28. 3 3
      src/components/FormCreate/src/useFormCreateDesigner.ts
  29. 0 61
      src/components/FormCreate/src/utils/index.ts
  30. 1 1
      src/components/Icon/src/Icon.vue
  31. 223 0
      src/components/MarkdownView/index.vue
  32. 1 0
      src/config/axios/service.ts
  33. 1 1
      src/layout/Layout.vue
  34. 1 1
      src/permission.ts
  35. 20 0
      src/router/modules/remaining.ts
  36. 22 4
      src/utils/dict.ts
  37. 18 0
      src/utils/download.ts
  38. 0 166
      src/views/ai/chat/index.vue
  39. 472 0
      src/views/ai/chat/index/components/conversation/ConversationList.vue
  40. 145 0
      src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue
  41. 282 0
      src/views/ai/chat/index/components/message/MessageList.vue
  42. 83 0
      src/views/ai/chat/index/components/message/MessageListEmpty.vue
  43. 15 0
      src/views/ai/chat/index/components/message/MessageLoading.vue
  44. 46 0
      src/views/ai/chat/index/components/message/MessageNewConversation.vue
  45. 53 0
      src/views/ai/chat/index/components/role/RoleCategoryList.vue
  46. 48 0
      src/views/ai/chat/index/components/role/RoleHeader.vue
  47. 174 0
      src/views/ai/chat/index/components/role/RoleList.vue
  48. 289 0
      src/views/ai/chat/index/components/role/RoleRepository.vue
  49. 773 0
      src/views/ai/chat/index/index.vue
  50. 163 0
      src/views/ai/chat/manager/ChatConversationList.vue
  51. 175 0
      src/views/ai/chat/manager/ChatMessageList.vue
  52. 20 0
      src/views/ai/chat/manager/index.vue
  53. 162 0
      src/views/ai/image/index/components/ImageCard.vue
  54. 224 0
      src/views/ai/image/index/components/ImageDetail.vue
  55. 233 0
      src/views/ai/image/index/components/ImageList.vue
  56. 320 0
      src/views/ai/image/index/components/dall3/index.vue
  57. 326 0
      src/views/ai/image/index/components/midjourney/index.vue
  58. 212 0
      src/views/ai/image/index/components/other/index.vue
  59. 272 0
      src/views/ai/image/index/components/stableDiffusion/index.vue
  60. 140 0
      src/views/ai/image/index/index.vue
  61. 251 0
      src/views/ai/image/manager/index.vue
  62. 132 0
      src/views/ai/model/apiKey/ApiKeyForm.vue
  63. 180 0
      src/views/ai/model/apiKey/index.vue
  64. 181 0
      src/views/ai/model/chatModel/ChatModelForm.vue
  65. 185 0
      src/views/ai/model/chatModel/index.vue
  66. 183 0
      src/views/ai/model/chatRole/ChatRoleForm.vue
  67. 187 0
      src/views/ai/model/chatRole/index.vue
  68. 21 0
      src/views/ai/music/components/index.vue
  69. 9 0
      src/views/ai/music/components/list/audioBar/index.vue
  70. 94 0
      src/views/ai/music/components/list/index.vue
  71. 29 0
      src/views/ai/music/components/list/songCard/index.vue
  72. 33 0
      src/views/ai/music/components/list/songInfo/index.vue
  73. 55 0
      src/views/ai/music/components/mode/desc.vue
  74. 44 0
      src/views/ai/music/components/mode/index.vue
  75. 83 0
      src/views/ai/music/components/mode/lyric.vue
  76. 25 0
      src/views/ai/music/components/title/index.vue
  77. 292 0
      src/views/ai/music/manager/index.vue
  78. 432 0
      src/views/ai/utils/constants.ts
  79. 13 0
      src/views/ai/utils/utils.ts
  80. 238 0
      src/views/ai/write/manager/index.vue
  81. 203 0
      src/views/ai/writer/index/components/Left.vue
  82. 105 0
      src/views/ai/writer/index/components/Right.vue
  83. 31 0
      src/views/ai/writer/index/components/Tag.vue
  84. 77 0
      src/views/ai/writer/index/index.vue
  85. 0 1
      src/views/bpm/category/index.vue
  86. 16 9
      src/views/crm/statistics/performance/components/ContractCountPerformance.vue
  87. 16 9
      src/views/crm/statistics/performance/components/ContractPricePerformance.vue
  88. 17 13
      src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue
  89. 9 20
      src/views/crm/statistics/performance/index.vue
  90. 1 5
      src/views/infra/job/index.vue
  91. 1 1
      src/views/infra/job/logger/JobLogDetail.vue
  92. 1 1
      src/views/infra/webSocket/index.vue
  93. 24 12
      src/views/mall/product/spu/components/SkuList.vue
  94. 6 6
      src/views/mall/product/spu/form/ProductAttributes.vue
  95. 7 7
      src/views/mall/product/spu/form/SkuForm.vue
  96. 226 0
      src/views/mall/promotion/kefu/components/KeFuConversationList.vue
  97. 399 0
      src/views/mall/promotion/kefu/components/KeFuMessageList.vue
  98. 二進制
      src/views/mall/promotion/kefu/components/asserts/a.png
  99. 二進制
      src/views/mall/promotion/kefu/components/asserts/aini.png
  100. 二進制
      src/views/mall/promotion/kefu/components/asserts/aixin.png

+ 4 - 2
package.json

@@ -6,7 +6,7 @@
   "private": false,
   "scripts": {
     "i": "pnpm install",
-    "dev": "vite",
+    "dev": "vite --mode env.local",
     "dev-server": "vite --mode dev",
     "ts:check": "vue-tsc --noEmit",
     "build:local": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build",
@@ -29,6 +29,7 @@
     "@form-create/designer": "^3.1.3",
     "@form-create/element-ui": "^3.1.24",
     "@iconify/iconify": "^3.1.1",
+    "@microsoft/fetch-event-source": "^2.0.1",
     "@videojs-player/vue": "^1.0.0",
     "@vueuse/core": "^10.9.0",
     "@wangeditor/editor": "^5.1.23",
@@ -46,11 +47,12 @@
     "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",
     "lodash-es": "^4.17.21",
+    "marked": "^12.0.2",
     "min-dash": "^4.1.1",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",

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

+ 65 - 0
src/api/ai/chat/conversation/index.ts

@@ -0,0 +1,65 @@
+import request from '@/config/axios'
+
+// AI 聊天对话 VO
+export interface ChatConversationVO {
+  id: number // ID 编号
+  userId: number // 用户编号
+  title: string // 对话标题
+  pinned: boolean // 是否置顶
+  roleId: number // 角色编号
+  modelId: number // 模型编号
+  model: string // 模型标志
+  temperature: number // 温度参数
+  maxTokens: number // 单条回复的最大 Token 数量
+  maxContexts: number // 上下文的最大 Message 数量
+  createTime?: Date // 创建时间
+  // 额外字段
+  systemMessage?: string // 角色设定
+  modelName?: string // 模型名字
+  roleAvatar?: string // 角色头像
+  modelMaxTokens?: string // 模型的单条回复的最大 Token 数量
+  modelMaxContexts?: string // 模型的上下文的最大 Message 数量
+}
+
+// AI 聊天对话 API
+export const ChatConversationApi = {
+  // 获得【我的】聊天对话
+  getChatConversationMy: async (id: number) => {
+    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: string) => {
+    return await request.delete({ url: `/ai/chat/conversation/delete-my?id=${id}` })
+  },
+
+  // 删除【我的】所有对话,置顶除外
+  deleteChatConversationMyByUnpinned: async () => {
+    return await request.delete({ url: `/ai/chat/conversation/delete-by-unpinned` })
+  },
+
+  // 获得【我的】聊天对话列表
+  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}` })
+  }
+}

+ 83 - 0
src/api/ai/chat/message/index.ts

@@ -0,0 +1,83 @@
+import request from '@/config/axios'
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+import { getAccessToken } from '@/utils/auth'
+import { config } from '@/config/axios/config'
+
+// 聊天VO
+export interface ChatMessageVO {
+  id: number // 编号
+  conversationId: number // 对话编号
+  type: string // 消息类型
+  userId: string // 用户编号
+  roleId: string // 角色编号
+  model: number // 模型标志
+  modelId: number // 模型编号
+  content: string // 聊天内容
+  tokens: number // 消耗 Token 数量
+  createTime: Date // 创建时间
+  roleAvatar: string // 角色头像
+  userAvatar: string // 创建时间
+}
+
+// AI chat 聊天
+export const ChatMessageApi = {
+  // 消息列表
+  getChatMessageListByConversationId: async (conversationId: number | null) => {
+    return await request.get({
+      url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`
+    })
+  },
+
+  // 发送 Stream 消息
+  // 为什么不用 axios 呢?因为它不支持 SSE 调用
+  sendChatMessageStream: async (
+    conversationId: number,
+    content: string,
+    ctrl,
+    enableContext: boolean,
+    onMessage,
+    onError,
+    onClose
+  ) => {
+    const token = getAccessToken()
+    return fetchEventSource(`${config.base_url}/ai/chat/message/send-stream`, {
+      method: 'post',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      openWhenHidden: true,
+      body: JSON.stringify({
+        conversationId,
+        content,
+        useContext: enableContext
+      }),
+      onmessage: onMessage,
+      onerror: onError,
+      onclose: onClose,
+      signal: ctrl.signal
+    })
+  },
+
+  // 删除消息
+  deleteChatMessage: async (id: string) => {
+    return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
+  },
+
+  // 删除指定对话的消息
+  deleteByConversationId: async (conversationId: number) => {
+    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}` })
+  }
+}

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

@@ -0,0 +1,103 @@
+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: any // 配置 Map<string, string>
+  taskId: number // 任务编号
+  buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮
+  createTime: Date // 创建时间
+  finishTime: Date // 完成时间
+}
+
+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 ImageMidjourneyActionVO {
+  id: number // 图片编号
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+}
+
+export interface ImageMidjourneyButtonsVO {
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+  emoji: string // 图标 emoji
+  label: string // Make Variations 文本
+  style: number // 样式: 2(Primary)、3(Green)
+}
+
+// AI 图片 API
+export const ImageApi = {
+  // 获取【我的】绘图分页
+  getImagePageMy: async (params: PageParam) => {
+    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: ImageMidjourneyActionVO) => {
+    return await request.post({ url: `/ai/image/midjourney/action`, data })
+  },
+
+  // ================ 绘图管理 ================
+
+  // 查询绘画分页
+  getImagePage: async (params: any) => {
+    return await request.get({ url: `/ai/image/page`, params })
+  },
+
+  // 更新绘画发布状态
+  updateImage: async (data: any) => {
+    return await request.put({ url: '/ai/image/update-public-status', data })
+  },
+
+  // 删除绘画
+  deleteImage: async (id: number) => {
+    return await request.delete({ url: `/ai/image/delete?id=` + id })
+  }
+}

+ 44 - 0
src/api/ai/model/apiKey/index.ts

@@ -0,0 +1,44 @@
+import request from '@/config/axios'
+
+// AI API 密钥 VO
+export interface ApiKeyVO {
+  id: number // 编号
+  name: string // 名称
+  apiKey: string // 密钥
+  platform: string // 平台
+  url: string // 自定义 API 地址
+  status: number // 状态
+}
+
+// AI API 密钥 API
+export const ApiKeyApi = {
+  // 查询 API 密钥分页
+  getApiKeyPage: async (params: any) => {
+    return await request.get({ url: `/ai/api-key/page`, params })
+  },
+
+  // 获得 API 密钥列表
+  getApiKeySimpleList: async () => {
+    return await request.get({ url: `/ai/api-key/simple-list` })
+  },
+
+  // 查询 API 密钥详情
+  getApiKey: async (id: number) => {
+    return await request.get({ url: `/ai/api-key/get?id=` + id })
+  },
+
+  // 新增 API 密钥
+  createApiKey: async (data: ApiKeyVO) => {
+    return await request.post({ url: `/ai/api-key/create`, data })
+  },
+
+  // 修改 API 密钥
+  updateApiKey: async (data: ApiKeyVO) => {
+    return await request.put({ url: `/ai/api-key/update`, data })
+  },
+
+  // 删除 API 密钥
+  deleteApiKey: async (id: number) => {
+    return await request.delete({ url: `/ai/api-key/delete?id=` + id })
+  }
+}

+ 53 - 0
src/api/ai/model/chatModel/index.ts

@@ -0,0 +1,53 @@
+import request from '@/config/axios'
+
+// AI 聊天模型 VO
+export interface ChatModelVO {
+  id: number // 编号
+  keyId: number // API 秘钥编号
+  name: string // 模型名字
+  model: string // 模型标识
+  platform: string // 模型平台
+  sort: number // 排序
+  status: number // 状态
+  temperature: number // 温度参数
+  maxTokens: number // 单条回复的最大 Token 数量
+  maxContexts: number // 上下文的最大 Message 数量
+}
+
+// AI 聊天模型 API
+export const ChatModelApi = {
+  // 查询聊天模型分页
+  getChatModelPage: async (params: any) => {
+    return await request.get({ url: `/ai/chat-model/page`, params })
+  },
+
+  // 获得聊天模型列表
+  getChatModelSimpleList: async (status?: number) => {
+    return await request.get({
+      url: `/ai/chat-model/simple-list`,
+      params: {
+        status
+      }
+    })
+  },
+
+  // 查询聊天模型详情
+  getChatModel: async (id: number) => {
+    return await request.get({ url: `/ai/chat-model/get?id=` + id })
+  },
+
+  // 新增聊天模型
+  createChatModel: async (data: ChatModelVO) => {
+    return await request.post({ url: `/ai/chat-model/create`, data })
+  },
+
+  // 修改聊天模型
+  updateChatModel: async (data: ChatModelVO) => {
+    return await request.put({ url: `/ai/chat-model/update`, data })
+  },
+
+  // 删除聊天模型
+  deleteChatModel: async (id: number) => {
+    return await request.delete({ url: `/ai/chat-model/delete?id=` + id })
+  }
+}

+ 80 - 0
src/api/ai/model/chatRole/index.ts

@@ -0,0 +1,80 @@
+import request from '@/config/axios'
+
+// AI 聊天角色 VO
+export interface ChatRoleVO {
+  id: number // 角色编号
+  modelId: number // 模型编号
+  name: string // 角色名称
+  avatar: string // 角色头像
+  category: string // 角色类别
+  sort: number // 角色排序
+  description: string // 角色描述
+  systemMessage: string // 角色设定
+  welcomeMessage: string // 角色设定
+  publicStatus: boolean // 是否公开
+  status: number // 状态
+}
+
+// AI 聊天角色 分页请求 vo
+export interface ChatRolePageReqVO {
+  name?: string // 角色名称
+  category?: string // 角色类别
+  publicStatus: boolean // 是否公开
+  pageNo: number // 是否公开
+  pageSize: number // 是否公开
+}
+
+// AI 聊天角色 API
+export const ChatRoleApi = {
+  // 查询聊天角色分页
+  getChatRolePage: async (params: any) => {
+    return await request.get({ url: `/ai/chat-role/page`, params })
+  },
+
+  // 查询聊天角色详情
+  getChatRole: async (id: number) => {
+    return await request.get({ url: `/ai/chat-role/get?id=` + id })
+  },
+
+  // 新增聊天角色
+  createChatRole: async (data: ChatRoleVO) => {
+    return await request.post({ url: `/ai/chat-role/create`, data })
+  },
+
+  // 修改聊天角色
+  updateChatRole: async (data: ChatRoleVO) => {
+    return await request.put({ url: `/ai/chat-role/update`, data })
+  },
+
+  // 删除聊天角色
+  deleteChatRole: async (id: number) => {
+    return await request.delete({ url: `/ai/chat-role/delete?id=` + id })
+  },
+
+  // ======= chat 聊天
+
+  // 获取 my role
+  getMyPage: async (params: ChatRolePageReqVO) => {
+    return await request.get({ url: `/ai/chat-role/my-page`, params})
+  },
+
+  // 获取角色分类
+  getCategoryList: async () => {
+    return await request.get({ url: `/ai/chat-role/category-list`})
+  },
+
+  // 创建角色
+  createMy: async (data: ChatRoleVO) => {
+    return await request.post({ url: `/ai/chat-role/create-my`, data})
+  },
+
+  // 更新角色
+  updateMy: async (data: ChatRoleVO) => {
+    return await request.put({ url: `/ai/chat-role/update-my`, data})
+  },
+
+  // 删除角色 my
+  deleteMy: async (id: number) => {
+    return await request.delete({ url: `/ai/chat-role/delete-my?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 })
+  }
+}

+ 65 - 0
src/api/ai/writer/index.ts

@@ -0,0 +1,65 @@
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+import request from '@/config/axios'
+
+import { getAccessToken } from '@/utils/auth'
+import { config } from '@/config/axios/config'
+import { AiWriteTypeEnum } from '@/views/ai/utils/constants'
+
+export interface WriteVO {
+  type: AiWriteTypeEnum.WRITING | AiWriteTypeEnum.REPLY // 1:撰写 2:回复
+  prompt: string // 写作内容提示 1。撰写 2回复
+  originalContent: string // 原文
+  length: number // 长度
+  format: number // 格式
+  tone: number // 语气
+  language: number // 语言
+  userId?: number // 用户编号
+  platform?: string // 平台
+  model?: string // 模型
+  generatedContent?: string // 生成的内容
+  errorMessage: string // 错误信息
+  createTime?: Date // 创建时间
+}
+
+// TODO @hhero:搞成 WriteApi,类似 ConversationApi 一样。这样更有类的概念,后续引入某个 Api,然后调用它的方法就可以了。
+export const writeStream = ({
+  data,
+  onClose,
+  onMessage,
+  onError,
+  ctrl
+}: {
+  data: WriteVO
+  onMessage?: (res: any) => void
+  onError?: (...args: any[]) => void
+  onClose?: (...args: any[]) => void
+  ctrl: AbortController
+}) => {
+  const token = getAccessToken()
+  return fetchEventSource(`${config.base_url}/ai/write/generate-stream`, {
+    method: 'post',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${token}`
+    },
+    openWhenHidden: true,
+    body: JSON.stringify(data),
+    onmessage: onMessage,
+    onerror: onError,
+    onclose: onClose,
+    signal: ctrl.signal
+  })
+}
+
+// AI 写作 API
+export const WriteApi = {
+  // 查询AI 写作分页
+  getWritePage: async (params: any) => {
+    return await request.get({ url: `/ai/write/page`, params })
+  },
+
+  // 删除AI 写作
+  deleteWrite: async (id: number) => {
+    return await request.delete({ url: `/ai/write/delete?id=` + id })
+  }
+}

+ 1 - 0
src/api/infra/jobLog/index.ts

@@ -12,6 +12,7 @@ export interface JobLogVO {
   duration: string
   status: number
   createTime: string
+  result: string
 }
 
 // 任务日志列表

+ 35 - 0
src/api/mall/promotion/kefu/conversation/index.ts

@@ -0,0 +1,35 @@
+import request from '@/config/axios'
+
+export interface KeFuConversationRespVO {
+  id: number // 编号
+  userId: number // 会话所属用户
+  userAvatar: string // 会话所属用户头像
+  userNickname: string // 会话所属用户昵称
+  lastMessageTime: Date // 最后聊天时间
+  lastMessageContent: string // 最后聊天内容
+  lastMessageContentType: number // 最后发送的消息类型
+  adminPinned: boolean // 管理端置顶
+  userDeleted: boolean // 用户是否可见
+  adminDeleted: boolean // 管理员是否可见
+  adminUnreadMessageCount: number // 管理员未读消息数
+  createTime?: string // 创建时间
+}
+
+// 客服会话 API
+export const KeFuConversationApi = {
+  // 获得客服会话列表
+  getConversationList: async () => {
+    return await request.get({ url: '/promotion/kefu-conversation/list' })
+  },
+  // 客服会话置顶
+  updateConversationPinned: async (data: any) => {
+    return await request.put({
+      url: '/promotion/kefu-conversation/update-conversation-pinned',
+      data
+    })
+  },
+  // 删除客服会话
+  deleteConversation: async (id: number) => {
+    return await request.get({ url: '/promotion/kefu-conversation/delete?id' + id })
+  }
+}

+ 36 - 0
src/api/mall/promotion/kefu/message/index.ts

@@ -0,0 +1,36 @@
+import request from '@/config/axios'
+
+export interface KeFuMessageRespVO {
+  id: number // 编号
+  conversationId: number // 会话编号
+  senderId: number // 发送人编号
+  senderAvatar: string // 发送人头像
+  senderType: number // 发送人类型
+  receiverId: number // 接收人编号
+  receiverType: number // 接收人类型
+  contentType: number // 消息类型
+  content: string // 消息
+  readStatus: boolean // 是否已读
+  createTime: Date // 创建时间
+}
+
+// 客服会话 API
+export const KeFuMessageApi = {
+  // 发送客服消息
+  sendKeFuMessage: async (data: any) => {
+    return await request.post({
+      url: '/promotion/kefu-message/send',
+      data
+    })
+  },
+  // 更新客服消息已读状态
+  updateKeFuMessageReadStatus: async (conversationId: number) => {
+    return await request.put({
+      url: '/promotion/kefu-message/update-read-status?conversationId=' + conversationId
+    })
+  },
+  // 获得消息分页数据
+  getKeFuMessagePage: async (params: any) => {
+    return await request.get({ url: '/promotion/kefu-message/page', params })
+  }
+}

+ 1 - 0
src/assets/ai/copy-style2.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="1715606039621" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M878.250667 981.333333H375.338667a104.661333 104.661333 0 0 1-104.661334-104.661333V375.338667a104.661333 104.661333 0 0 1 104.661334-104.661334h502.912a104.661333 104.661333 0 0 1 104.661333 104.661334v502.912C981.333333 934.485333 934.485333 981.333333 878.250667 981.333333zM375.338667 364.373333a10.666667 10.666667 0 0 0-10.922667 10.965334v502.912c0 6.229333 4.693333 10.922667 10.922667 10.922666h502.912a10.666667 10.666667 0 0 0 10.922666-10.922666V375.338667a10.666667 10.666667 0 0 0-10.922666-10.922667H375.338667z" fill="#ffffff" p-id="4257"></path><path d="M192.597333 753.322667H147.328A104.661333 104.661333 0 0 1 42.666667 648.661333V147.328A104.661333 104.661333 0 0 1 147.328 42.666667H650.24a104.661333 104.661333 0 0 1 104.618667 104.661333v49.962667c0 26.538667-20.309333 46.848-46.848 46.848a46.037333 46.037333 0 0 1-46.848-46.848V147.328a10.666667 10.666667 0 0 0-10.922667-10.965333H147.328a10.666667 10.666667 0 0 0-10.965333 10.965333V650.24c0 6.229333 4.693333 10.922667 10.965333 10.922667h45.269333c26.538667 0 46.848 20.309333 46.848 46.848 0 26.538667-21.845333 45.312-46.848 45.312z" fill="#ffffff" p-id="4258"></path></svg>

+ 1 - 0
src/assets/ai/copy.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="1715352878351" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1499" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M624.5 786.3c92.9 0 168.2-75.3 168.2-168.2V309c0-92.4-75.3-168.2-168.2-168.2H303.6c-92.4 0-168.2 75.3-168.2 168.2v309.1c0 92.4 75.3 168.2 168.2 168.2h320.9zM178.2 618.1V309c0-69.4 56.1-125.5 125.5-125.5h320.9c69.4 0 125.5 56.1 125.5 125.5v309.1c0 69.4-56.1 125.5-125.5 125.5h-321c-69.4 0-125.4-56.1-125.4-125.5z" p-id="1500" fill="#8a8a8a"></path><path d="M849.8 295.1v361.5c0 102.7-83.6 186.3-186.3 186.3H279.1v42.7h384.4c126.3 0 229.1-102.8 229.1-229.1V295.1h-42.8zM307.9 361.8h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4zM307.9 484.6h312.3c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.9 9.6 21.4 21.4 21.4z" p-id="1501" fill="#8a8a8a"></path><path d="M620.2 607.4c11.8 0 21.4-9.6 21.4-21.4 0-11.8-9.6-21.4-21.4-21.4H307.9c-11.8 0-21.4 9.6-21.4 21.4 0 11.8 9.6 21.4 21.4 21.4h312.3z" p-id="1502" fill="#8a8a8a"></path></svg>

二進制
src/assets/ai/dall2.jpg


二進制
src/assets/ai/dall3.jpg


+ 1 - 0
src/assets/ai/delete.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="1715354120346" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3256" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M907.1 263.7H118.9c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4H907c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3257"></path><path d="M772.5 928.3H257.4c-27.7 0-50.2-22.5-50.2-50.2V247.2c0-9.1 7.3-16.4 16.4-16.4H801c12.1 0 21.9 9.8 21.9 21.9v625.2c0 27.8-22.6 50.4-50.4 50.4zM240 263.7v614.4c0 9.6 7.8 17.4 17.4 17.4h515.2c9.7 0 17.5-7.9 17.5-17.5V263.7H240zM657.4 131.1H368.6c-9.1 0-16.4-7.3-16.4-16.4s7.3-16.4 16.4-16.4h288.7c9.1 0 16.4 7.3 16.4 16.4s-7.3 16.4-16.3 16.4z" fill="#8a8a8a" p-id="3258"></path><path d="M416 754.5c-9.1 0-16.4-7.3-16.4-16.4V517.8c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0.1 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" p-id="3259"></path><path d="M416 465.2c-9.1 0-16.4-7.3-16.4-16.4v-59.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v59.4c0.1 9.1-7.3 16.4-16.4 16.4zM604.9 754.5c-9.1 0-16.4-7.3-16.4-16.4v-67.2c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4V738c0 9.1-7.3 16.5-16.4 16.5z" fill="#8a8a8a" opacity=".4" p-id="3260"></path><path d="M604.9 619.1c-9.1 0-16.4-7.3-16.4-16.4V389.4c0-9.1 7.3-16.4 16.4-16.4s16.4 7.3 16.4 16.4v213.3c0 9.1-7.3 16.4-16.4 16.4z" fill="#8a8a8a" p-id="3261"></path></svg>

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


二進制
src/assets/ai/qingxi.jpg


二進制
src/assets/ai/ziran.jpg


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

+ 113 - 8
src/components/FormCreate/src/components/useApiSelect.tsx

@@ -27,6 +27,11 @@ export const useApiSelect = (option: ApiSelectProps) => {
         type: String,
         default: 'GET'
       },
+      // 选项解析函数
+      parseFunc: {
+        type: String,
+        default: ''
+      },
       // 请求参数
       data: {
         type: String,
@@ -41,35 +46,121 @@ export const useApiSelect = (option: ApiSelectProps) => {
       multiple: {
         type: Boolean,
         default: false
+      },
+      // 是否远程搜索
+      remote: {
+        type: Boolean,
+        default: false
+      },
+      // 远程搜索时携带的参数
+      remoteField: {
+        type: String,
+        default: 'label'
       }
     },
     setup(props) {
       const attrs = useAttrs()
       const options = ref<any[]>([]) // 下拉数据
+      const loading = ref(false) // 是否正在从远程获取数据
+      const queryParam = ref<any>() // 当前输入的值
       const getOptions = async () => {
         options.value = []
         // 接口选择器
         if (isEmpty(props.url)) {
           return
         }
-        let data = []
         switch (props.method) {
           case 'GET':
-            data = await request.get({ url: props.url })
+            let url: string = props.url
+            if (props.remote) {
+              url = `${url}?${props.remoteField}=${queryParam.value}`
+            }
+            parseOptions(await request.get({ url: url }))
             break
           case 'POST':
-            data = await request.post({ url: props.url, data: jsonParse(props.data) })
+            const data: any = jsonParse(props.data)
+            if (props.remote) {
+              data[props.remoteField] = queryParam.value
+            }
+            parseOptions(await request.post({ url: props.url, data: data }))
             break
         }
+      }
+
+      function parseOptions(data: any) {
+        //  情况一:如果有自定义解析函数优先使用自定义解析
+        if (!isEmpty(props.parseFunc)) {
+          options.value = parseFunc()?.(data)
+          return
+        }
+        // 情况二:返回的直接是一个列表
+        if (Array.isArray(data)) {
+          parseOptions0(data)
+          return
+        }
+        // 情况二:返回的是分页数据,尝试读取 list
+        data = data.list
+        if (!!data && Array.isArray(data)) {
+          parseOptions0(data)
+          return
+        }
+        // 情况三:不是 yudao-vue-pro 标准返回
+        console.warn(
+          `接口[${props.url}] 返回结果不是 yudao-vue-pro 标准返回建议采用自定义解析函数处理`
+        )
+      }
 
+      function parseOptions0(data: any[]) {
         if (Array.isArray(data)) {
           options.value = data.map((item: any) => ({
-            label: item[props.labelField],
-            value: item[props.valueField]
+            label: parseExpression(item, props.labelField),
+            value: parseExpression(item, props.valueField)
           }))
           return
         }
-        console.error(`接口[${props.url}] 返回结果不是一个数组`)
+        console.warn(`接口[${props.url}] 返回结果不是一个数组`)
+      }
+
+      function parseFunc() {
+        let parse: any = null
+        if (!!props.parseFunc) {
+          // 解析字符串函数
+          parse = new Function(`return ${props.parseFunc}`)()
+        }
+        return parse
+      }
+
+      function parseExpression(data: any, template: string) {
+        // 检测是否使用了表达式
+        if (template.indexOf('${') === -1) {
+          return data[template]
+        }
+        // 正则表达式匹配模板字符串中的 ${...}
+        const pattern = /\$\{([^}]*)}/g
+        // 使用replace函数配合正则表达式和回调函数来进行替换
+        return template.replace(pattern, (_, expr) => {
+          // expr 是匹配到的 ${} 内的表达式(这里是属性名),从 data 中获取对应的值
+          const result = data[expr.trim()] // 去除前后空白,以防用户输入带空格的属性名
+          if (!result) {
+            console.warn(
+              `接口选择器选项模版[${template}][${expr.trim()}] 解析值失败结果为[${result}], 请检查属性名称是否存在于接口返回值中,存在则忽略此条!!!`
+            )
+          }
+          return result
+        })
+      }
+
+      const remoteMethod = async (query: any) => {
+        if (!query) {
+          return
+        }
+        loading.value = true
+        try {
+          queryParam.value = query
+          await getOptions()
+        } finally {
+          loading.value = false
+        }
       }
 
       onMounted(async () => {
@@ -80,15 +171,29 @@ export const useApiSelect = (option: ApiSelectProps) => {
         if (props.multiple) {
           // fix:多写此步是为了解决 multiple 属性问题
           return (
-            <el-select class="w-1/1" {...attrs} multiple>
+            <el-select
+              class="w-1/1"
+              multiple
+              loading={loading.value}
+              {...attrs}
+              remote={props.remote}
+              {...(props.remote && { remoteMethod: remoteMethod })}
+            >
               {options.value.map((item, index) => (
                 <el-option key={index} label={item.label} value={item.value} />
               ))}
             </el-select>
           )
         }
+        debugger
         return (
-          <el-select class="w-1/1" {...attrs}>
+          <el-select
+            class="w-1/1"
+            loading={loading.value}
+            {...attrs}
+            remote={props.remote}
+            {...(props.remote && { remoteMethod: remoteMethod })}
+          >
             {options.value.map((item, index) => (
               <el-option key={index} label={item.label} value={item.value} />
             ))}

+ 51 - 17
src/components/FormCreate/src/config/selectRule.ts

@@ -13,12 +13,30 @@ const selectRule = [
     control: [
       {
         value: 'select',
-        condition: '=',
+        condition: '==',
         method: 'hidden',
-        rule: ['multiple']
+        rule: [
+          'multiple',
+          'clearable',
+          'collapseTags',
+          'multipleLimit',
+          'allowCreate',
+          'filterable',
+          'noMatchText',
+          'remote',
+          'remoteMethod',
+          'reserveKeyword',
+          'defaultFirstOption',
+          'automaticDropdown'
+        ]
       }
     ]
   },
+  {
+    type: 'switch',
+    field: 'filterable',
+    title: '是否可搜索'
+  },
   { type: 'switch', field: 'multiple', title: '是否多选' },
   {
     type: 'switch',
@@ -43,27 +61,12 @@ const selectRule = [
     title: 'autocomplete 属性'
   },
   { type: 'input', field: 'placeholder', title: '占位符' },
-  {
-    type: 'switch',
-    field: 'filterable',
-    title: '是否可搜索'
-  },
   { type: 'switch', field: 'allowCreate', title: '是否允许用户创建新条目' },
   {
     type: 'input',
     field: 'noMatchText',
     title: '搜索条件无匹配时显示的文字'
   },
-  {
-    type: 'switch',
-    field: 'remote',
-    title: '其中的选项是否从服务器远程加载'
-  },
-  {
-    type: 'Struct',
-    field: 'remoteMethod',
-    title: '自定义远程搜索方法'
-  },
   { type: 'input', field: 'noDataText', title: '选项为空时显示的文字' },
   {
     type: 'switch',
@@ -130,6 +133,7 @@ const apiSelectRule = [
     type: 'input',
     field: 'labelField',
     title: 'label 属性',
+    info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
     props: {
       placeholder: 'nickname'
     }
@@ -138,9 +142,39 @@ const apiSelectRule = [
     type: 'input',
     field: 'valueField',
     title: 'value 属性',
+    info: '可以使用 el 表达式:${属性},来实现复杂数据组合。如:${nickname}-${id}',
     props: {
       placeholder: 'id'
     }
+  },
+  {
+    type: 'input',
+    field: 'parseFunc',
+    title: '选项解析函数',
+    info: `data 为接口返回值,需要写一个匿名函数解析返回值为选择器 options 列表
+    (data: any)=>{ label: string; value: any }[]`,
+    props: {
+      autosize: true,
+      rows: { minRows: 2, maxRows: 6 },
+      type: 'textarea',
+      placeholder: `
+        function (data) {
+            console.log(data)
+            return data.list.map(item=> ({label: item.nickname,value: item.id}))
+        }`
+    }
+  },
+  {
+    type: 'switch',
+    field: 'remote',
+    info: '是否可搜索',
+    title: '其中的选项是否从服务器远程加载'
+  },
+  {
+    type: 'input',
+    field: 'remoteField',
+    title: '请求参数',
+    info: '远程请求时请求携带的参数名称,如:name'
   }
 ]
 

+ 3 - 1
src/components/FormCreate/src/config/useDictSelectRule.ts

@@ -2,6 +2,7 @@ import { generateUUID } from '@/utils'
 import * as DictDataApi from '@/api/system/dict/dict.type'
 import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
 import { selectRule } from '@/components/FormCreate/src/config/selectRule'
+import { cloneDeep } from 'lodash-es'
 
 /**
  * 字典选择器规则,如果规则使用到动态数据则需要单独配置不能使用 useSelectRule
@@ -9,6 +10,7 @@ import { selectRule } from '@/components/FormCreate/src/config/selectRule'
 export const useDictSelectRule = () => {
   const label = '字典选择器'
   const name = 'DictSelect'
+  const rules = cloneDeep(selectRule)
   const dictOptions = ref<{ label: string; value: string }[]>([]) // 字典类型下拉数据
   onMounted(async () => {
     const data = await DictDataApi.getSimpleDictTypeList()
@@ -55,7 +57,7 @@ export const useDictSelectRule = () => {
             { label: '布尔值', value: 'bool' }
           ]
         },
-        ...selectRule
+        ...rules
       ])
     }
   }

+ 3 - 1
src/components/FormCreate/src/config/useSelectRule.ts

@@ -2,6 +2,7 @@ import { generateUUID } from '@/utils'
 import { localeProps, makeRequiredRule } from '@/components/FormCreate/src/utils'
 import { selectRule } from '@/components/FormCreate/src/config/selectRule'
 import { SelectRuleOption } from '@/components/FormCreate/src/type'
+import { cloneDeep } from 'lodash-es'
 
 /**
  * 通用选择器规则 hook
@@ -11,6 +12,7 @@ import { SelectRuleOption } from '@/components/FormCreate/src/type'
 export const useSelectRule = (option: SelectRuleOption) => {
   const label = option.label
   const name = option.name
+  const rules = cloneDeep(selectRule)
   return {
     icon: option.icon,
     label,
@@ -28,7 +30,7 @@ export const useSelectRule = (option: SelectRuleOption) => {
       if (!option.props) {
         option.props = []
       }
-      return localeProps(t, name + '.props', [makeRequiredRule(), ...option.props, ...selectRule])
+      return localeProps(t, name + '.props', [makeRequiredRule(), ...option.props, ...rules])
     }
   }
 }

+ 3 - 3
src/components/FormCreate/src/useFormCreateDesigner.ts

@@ -71,9 +71,9 @@ export const useFormCreateDesigner = async (designer: Ref) => {
    */
   const buildSystemMenu = () => {
     // 移除自带的下拉选择器组件,使用 currencySelectRule 替代
-    designer.value?.removeMenuItem('select')
-    designer.value?.removeMenuItem('radio')
-    designer.value?.removeMenuItem('checkbox')
+    // designer.value?.removeMenuItem('select')
+    // designer.value?.removeMenuItem('radio')
+    // designer.value?.removeMenuItem('checkbox')
     const components = [userSelectRule, deptSelectRule, dictSelectRule, apiSelectRule0]
     const menu: Menu = {
       name: 'system',

+ 0 - 61
src/components/FormCreate/src/utils/index.ts

@@ -1,4 +1,3 @@
-// TODO puhui999: 借鉴一下 form-create-designer utils 方法 🤣 (导入不了只能先 copy 过来用下)
 export function makeRequiredRule() {
   return {
     type: 'Required',
@@ -17,63 +16,3 @@ export const localeProps = (t, prefix, rules) => {
     return rule
   })
 }
-
-export function upper(str) {
-  return str.replace(str[0], str[0].toLocaleUpperCase())
-}
-
-export function makeOptionsRule(t, to, userOptions) {
-  console.log(userOptions[0])
-  const options = [
-    { label: t('props.optionsType.struct'), value: 0 },
-    { label: t('props.optionsType.json'), value: 1 },
-    { label: '用户数据', value: 2 }
-  ]
-
-  const control = [
-    {
-      value: 0,
-      rule: [
-        {
-          type: 'TableOptions',
-          field: 'formCreate' + upper(to).replace('.', '>'),
-          props: { defaultValue: [] }
-        }
-      ]
-    },
-    {
-      value: 1,
-      rule: [
-        {
-          type: 'Struct',
-          field: 'formCreate' + upper(to).replace('.', '>'),
-          props: { defaultValue: [] }
-        }
-      ]
-    },
-    {
-      value: 2,
-      rule: [
-        {
-          type: 'TableOptions',
-          field: 'formCreate' + upper(to).replace('.', '>'),
-          props: { modelValue: [] }
-        }
-      ]
-    }
-  ]
-  options.splice(0, 0)
-  control.push()
-
-  return {
-    type: 'radio',
-    title: t('props.options'),
-    field: '_optionType',
-    value: 0,
-    options,
-    props: {
-      type: 'button'
-    },
-    control
-  }
-}

+ 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

+ 223 - 0
src/components/MarkdownView/index.vue

@@ -0,0 +1,223 @@
+<template>
+  <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'
+
+// 定义组件属性
+const props = defineProps({
+  content: {
+    type: String,
+    required: true
+  }
+})
+
+const message = useMessage() // 消息弹窗
+const { copy } = useClipboard() // 初始化 copy 到粘贴板
+const contentRef = ref()
+const contentHtml = ref<any>() // 渲染的html内容
+const { content } = toRefs(props) // 将 props 变为引用类型
+
+// 代码高亮:https://highlightjs.org/
+// 转换 markdown:marked
+
+/** marked 渲染器 */
+const renderer = {
+  code(code, language, c) {
+    let highlightHtml
+    try {
+      highlightHtml = hljs.highlight(code, { language: language, ignoreIllegals: true }).value
+    } catch (e) {
+      // skip
+    }
+    const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
+    return `<pre style="position: relative;">${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
+  }
+}
+
+// 配置 marked
+marked.use({
+  renderer: renderer
+})
+
+/** 监听 content 变化 */
+watch(content, async (newValue, oldValue) => {
+  await renderMarkdown(newValue)
+})
+
+/** 渲染 markdown */
+const renderMarkdown = async (content: string) => {
+  contentHtml.value = await marked(content)
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  // 解析转换 markdown
+  await renderMarkdown(props.content as string)
+  // 添加 copy 监听
+  contentRef.value.addEventListener('click', (e: any) => {
+    if (e.target.id === 'copy') {
+      copy(e.target?.dataset?.copy)
+      message.success('复制成功!')
+    }
+  })
+})
+</script>
+
+<style lang="scss">
+.markdown-view {
+  font-family: PingFang SC;
+  font-size: 0.95rem;
+  font-weight: 400;
+  line-height: 1.6rem;
+  letter-spacing: 0em;
+  text-align: left;
+  color: #3b3e55;
+  max-width: 100%;
+
+  pre {
+    position: relative;
+  }
+
+  pre code.hljs {
+    width: auto;
+  }
+
+  code.hljs {
+    border-radius: 6px;
+    padding-top: 20px;
+    width: auto;
+    @media screen and (min-width: 1536px) {
+      width: 960px;
+    }
+
+    @media screen and (max-width: 1536px) and (min-width: 1024px) {
+      width: calc(100vw - 400px - 64px - 32px * 2);
+    }
+
+    @media screen and (max-width: 1024px) and (min-width: 768px) {
+      width: calc(100vw - 32px * 2);
+    }
+
+    @media screen and (max-width: 768px) {
+      width: calc(100vw - 16px * 2);
+    }
+  }
+
+  p,
+  code.hljs {
+    margin-bottom: 16px;
+  }
+
+  p {
+    //margin-bottom: 1rem !important;
+    margin: 0;
+    margin-bottom: 3px;
+  }
+
+  /* 标题通用格式 */
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    color: var(--color-G900);
+    margin: 24px 0 8px;
+    font-weight: 600;
+  }
+
+  h1 {
+    font-size: 22px;
+    line-height: 32px;
+  }
+
+  h2 {
+    font-size: 20px;
+    line-height: 30px;
+  }
+
+  h3 {
+    font-size: 18px;
+    line-height: 28px;
+  }
+
+  h4 {
+    font-size: 16px;
+    line-height: 26px;
+  }
+
+  h5 {
+    font-size: 16px;
+    line-height: 24px;
+  }
+
+  h6 {
+    font-size: 16px;
+    line-height: 24px;
+  }
+
+  /* 列表(有序,无序) */
+  ul,
+  ol {
+    margin: 0 0 8px 0;
+    padding: 0;
+    font-size: 16px;
+    line-height: 24px;
+    color: #3b3e55; // var(--color-CG600);
+  }
+
+  li {
+    margin: 4px 0 0 20px;
+    margin-bottom: 1rem;
+  }
+
+  ol > li {
+    list-style-type: decimal;
+    margin-bottom: 1rem;
+    // 表达式,修复有序列表序号展示不全的问题
+    // &:nth-child(n + 10) {
+    //     margin-left: 30px;
+    // }
+
+    // &:nth-child(n + 100) {
+    //     margin-left: 30px;
+    // }
+  }
+
+  ul > li {
+    list-style-type: disc;
+    font-size: 16px;
+    line-height: 24px;
+    margin-right: 11px;
+    margin-bottom: 1rem;
+    color: #3b3e55; // var(--color-G900);
+  }
+
+  ol ul,
+  ol ul > li,
+  ul ul,
+  ul ul li {
+    // list-style: circle;
+    font-size: 16px;
+    list-style: none;
+    margin-left: 6px;
+    margin-bottom: 1rem;
+  }
+
+  ul ul ul,
+  ul ul ul li,
+  ol ol,
+  ol ol > li,
+  ol ul ul,
+  ol ul ul > li,
+  ul ol,
+  ul ol > li {
+    list-style: square;
+  }
+}
+</style>

+ 1 - 0
src/config/axios/service.ts

@@ -174,6 +174,7 @@ service.interceptors.response.use(
       if (msg === '无效的刷新令牌') {
         // hard coding:忽略这个提示,直接登出
         console.log(msg)
+        return handleAuthorized()
       } else {
         ElNotification.error({ title: msg })
       }

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

+ 1 - 1
src/permission.ts

@@ -83,7 +83,7 @@ router.beforeEach(async (to, from, next) => {
         const redirectPath = from.query.redirect || to.path
         // 修复跳转时不带参数的问题
         const redirect = decodeURIComponent(redirectPath as string)
-        const { basePath, paramsObject: query } = parseURL(redirect)
+        const { paramsObject: query } = parseURL(redirect)
         const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect, query }
         next(nextData)
       } else {

+ 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,

+ 22 - 4
src/utils/dict.ts

@@ -1,8 +1,8 @@
 /**
  * 数据字典工具类
  */
-import {useDictStoreWithOut} from '@/store/modules/dict'
-import {ElementPlusInfoType} from '@/types/elementPlus'
+import { useDictStoreWithOut } from '@/store/modules/dict'
+import { ElementPlusInfoType } from '@/types/elementPlus'
 
 const dictStore = useDictStoreWithOut()
 
@@ -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,
@@ -209,5 +216,16 @@ export enum DICT_TYPE {
 
   // ========== ERP - 企业资源计划模块  ==========
   ERP_AUDIT_STATUS = 'erp_audit_status', // ERP 审批状态
-  ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type' // 库存明细的业务类型
+  ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型
+
+  // ========== AI - 人工智能模块  ==========
+  AI_PLATFORM = 'ai_platform', // AI 平台
+  AI_IMAGE_STATUS = 'ai_image_status', // AI 图片状态
+  AI_MUSIC_STATUS = 'ai_music_status', // AI 音乐状态
+  AI_GENERATE_MODE = 'ai_generate_mode', // AI 生成模式
+  AI_WRITE_TYPE = 'ai_write_type', // AI 写作类型
+  AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度
+  AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
+  AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
+  AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言
 }

+ 18 - 0
src/utils/download.ts

@@ -32,6 +32,24 @@ const download = {
   // 下载 Markdown 方法
   markdown: (data: Blob, fileName: string) => {
     download0(data, fileName, 'text/markdown')
+  },
+  // 下载图片(允许跨域)
+  image: (url: string) => {
+    const image = new Image()
+    image.setAttribute('crossOrigin', 'anonymous')
+    image.src = url
+    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()
+    }
   }
 }
 

+ 0 - 166
src/views/ai/chat/index.vue

@@ -1,166 +0,0 @@
-<template>
-  <el-container>
-    <!-- 左侧:会话列表 -->
-    <el-aside width="260px" class="conversation-container">
-      <!-- 左顶部:新建对话 -->
-      <el-button class="w-1/1" type="primary">
-        <Icon icon="ep:plus" class="mr-5px" />
-        新建对话
-      </el-button>
-      <!-- 左顶部:搜索对话 -->
-      <el-input
-        v-model="searchName"
-        class="mt-10px"
-        placeholder="搜索历史记录"
-        @keyup="searchConversation"
-      >
-        <template #prefix>
-          <Icon icon="ep:search" />
-        </template>
-      </el-input>
-      <!-- 左中间:对话列表 -->
-      <div class="conversation-list" :style="{ height: leftHeight + 'px' }">
-        <el-row v-for="conversation in conversationList" :key="conversation.id">
-          <div
-            :class="conversation.id === conversationId ? 'conversation active' : 'conversation'"
-            @click="changeConversation(conversation)"
-          >
-            <el-image :src="conversation.avatar" class="avatar" />
-            <span class="title">{{ conversation.title }}</span>
-            <span class="button">
-              <!-- TODO 芋艿:缺置顶按钮 -->
-              <el-icon title="编辑" @click="updateConversationTitle(conversation)">
-                <Icon icon="ep:edit" />
-              </el-icon>
-              <el-icon title="删除会话" @click="deleteConversationTitle(conversation)">
-                <Icon icon="ep:delete" />
-              </el-icon>
-            </span>
-          </div>
-        </el-row>
-      </div>
-      <!-- 左底部:工具栏 TODO 芋艿:50% 不太对 -->
-      <div class="tool-box">
-        <sapn class="w-1/2"> <Icon icon="ep:user" /> 角色仓库 </sapn>
-        <sapn class="w-1/2"> <Icon icon="ep:delete" />清空未置顶对话</sapn>
-      </div>
-    </el-aside>
-    <!-- 右侧:会话详情 -->
-    <el-container class="detail-container">
-      <!-- 右顶部 TODO 芋艿:右对齐 -->
-      <el-header class="header">
-        <el-button>3.5-turbo-0125 <Icon icon="ep:setting" /></el-button>
-        <el-button>
-          <Icon icon="ep:user" />
-        </el-button>
-        <el-button>
-          <Icon icon="ep:download" />
-        </el-button>
-        <el-button>
-          <Icon icon="ep:arrow-up" />
-        </el-button>
-      </el-header>
-      <el-main>对话列表</el-main>
-      <el-footer>发送消息框</el-footer>
-    </el-container>
-  </el-container>
-</template>
-<script setup lang="ts">
-const conversationList = [
-  {
-    id: 1,
-    title: '测试标题',
-    avatar:
-      'http://test.yudao.iocoder.cn/96c787a2ce88bf6d0ce3cd8b6cf5314e80e7703cd41bf4af8cd2e2909dbd6b6d.png'
-  },
-  {
-    id: 2,
-    title: '测试对话',
-    avatar:
-      'http://test.yudao.iocoder.cn/96c787a2ce88bf6d0ce3cd8b6cf5314e80e7703cd41bf4af8cd2e2909dbd6b6d.png'
-  }
-]
-const conversationId = ref(1)
-const searchName = ref('')
-const leftHeight = window.innerHeight - 240 // TODO 芋艿:这里还不太对
-
-const changeConversation = (conversation) => {
-  console.log(conversation)
-  conversationId.value = conversation.id
-  // TODO 芋艿:待实现
-}
-
-const updateConversationTitle = (conversation) => {
-  console.log(conversation)
-  // TODO 芋艿:待实现
-}
-
-const deleteConversationTitle = (conversation) => {
-  console.log(conversation)
-  // TODO 芋艿:待实现
-}
-
-const searchConversation = () => {
-  // TODO 芋艿:待实现
-}
-</script>
-<style lang="scss" scoped>
-.conversation-container {
-  .conversation-list {
-    .conversation {
-      display: flex;
-      justify-content: flex-start;
-      width: 100%;
-      padding: 5px 5px 0 0;
-      cursor: pointer;
-      &.active {
-        // TODO 芋艿:这里不太对
-        background-color: #343540;
-        .button {
-          display: inline;
-        }
-      }
-      .title {
-        padding: 5px 10px;
-        max-width: 220px;
-        font-size: 14px;
-        overflow: hidden;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-      }
-      .avatar {
-        width: 28px;
-        height: 28px;
-        border-radius: 50%;
-      }
-      .button {
-        position: absolute;
-        right: 2px;
-        top: 16px;
-        .el-icon {
-          margin-right: 5px;
-        }
-      }
-    }
-
-    .tool-box {
-      display: flex;
-      justify-content: flex-start;
-      padding: 0 20px 10px 20px;
-      border-top: 1px solid black;
-    }
-  }
-}
-
-.detail-container {
-  .header {
-    width: 100%;
-    height: 30px;
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    justify-content: center;
-    padding-top: 10px;
-  }
-}
-</style>

+ 472 - 0
src/views/ai/chat/index/components/conversation/ConversationList.vue

@@ -0,0 +1,472 @@
+<!--  AI 对话  -->
+<template>
+  <el-aside width="260px" class="conversation-container h-100%">
+    <!-- 左顶部:对话 -->
+    <div class="h-100%">
+      <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
+        <Icon icon="ep:plus" class="mr-5px" />
+        新建对话
+      </el-button>
+
+      <!-- 左顶部:搜索对话 -->
+      <el-input
+        v-model="searchName"
+        size="large"
+        class="mt-10px search-input"
+        placeholder="搜索历史记录"
+        @keyup="searchConversation"
+      >
+        <template #prefix>
+          <Icon icon="ep:search" />
+        </template>
+      </el-input>
+
+      <!-- 左中间:对话列表 -->
+      <div class="conversation-list">
+        <!-- 情况一:加载中 -->
+        <el-empty v-if="loading" description="." :v-loading="loading" />
+        <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 -->
+        <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
+          <div
+            class="conversation-item classify-title"
+            v-if="conversationMap[conversationKey].length"
+          >
+            <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
+          </div>
+          <div
+            class="conversation-item"
+            v-for="conversation in conversationMap[conversationKey]"
+            :key="conversation.id"
+            @click="handleConversationClick(conversation.id)"
+            @mouseover="hoverConversationId = conversation.id"
+            @mouseout="hoverConversationId = ''"
+          >
+            <div
+              :class="
+                conversation.id === activeConversationId ? 'conversation active' : 'conversation'
+              "
+            >
+              <div class="title-wrapper">
+                <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
+                <span class="title">{{ conversation.title }}</span>
+              </div>
+              <div class="button-wrapper" v-show="hoverConversationId === conversation.id">
+                <el-button class="btn" link @click.stop="handleTop(conversation)">
+                  <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
+                  <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
+                </el-button>
+                <el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
+                  <el-icon title="编辑">
+                    <Icon icon="ep:edit" />
+                  </el-icon>
+                </el-button>
+                <el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
+                  <el-icon title="删除对话">
+                    <Icon icon="ep:delete" />
+                  </el-icon>
+                </el-button>
+              </div>
+            </div>
+          </div>
+        </div>
+        <!-- 底部占位  -->
+        <div class="h-160px w-100%"></div>
+      </div>
+    </div>
+
+    <!-- 左底部:工具栏 -->
+    <div class="tool-box">
+      <div @click="handleRoleRepository">
+        <Icon icon="ep:user" />
+        <el-text size="small">角色仓库</el-text>
+      </div>
+      <div @click="handleClearConversation">
+        <Icon icon="ep:delete" />
+        <el-text size="small">清空未置顶对话</el-text>
+      </div>
+    </div>
+
+    <!-- 角色仓库抽屉 -->
+    <el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px">
+      <RoleRepository />
+    </el-drawer>
+  </el-aside>
+</template>
+
+<script setup lang="ts">
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import RoleRepository from '../role/RoleRepository.vue'
+import { Bottom, Top } from '@element-plus/icons-vue'
+import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
+
+const message = useMessage() // 消息弹窗
+
+// 定义属性
+const searchName = ref<string>('') // 对话搜索
+const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null
+const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话
+const conversationList = ref([] as ChatConversationVO[]) // 对话列表
+const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
+const loading = ref<boolean>(false) // 加载中
+const loadingTime = ref<any>() // 加载中定时器
+
+// 定义组件 props
+const props = defineProps({
+  activeId: {
+    type: String || null,
+    required: true
+  }
+})
+
+// 定义钩子
+const emits = defineEmits([
+  'onConversationCreate',
+  'onConversationClick',
+  'onConversationClear',
+  'onConversationDelete'
+])
+
+/** 搜索对话 */
+const searchConversation = async (e) => {
+  // 恢复数据
+  if (!searchName.value.trim().length) {
+    conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
+  } else {
+    // 过滤
+    const filterValues = conversationList.value.filter((item) => {
+      return item.title.includes(searchName.value.trim())
+    })
+    conversationMap.value = await getConversationGroupByCreateTime(filterValues)
+  }
+}
+
+/** 点击对话 */
+const handleConversationClick = async (id: number) => {
+  // 过滤出选中的对话
+  const filterConversation = conversationList.value.filter((item) => {
+    return item.id === id
+  })
+  // 回调 onConversationClick
+  // noinspection JSVoidFunctionReturnValueUsed
+  const success = emits('onConversationClick', filterConversation[0])
+  // 切换对话
+  if (success) {
+    activeConversationId.value = id
+  }
+}
+
+/** 获取对话列表 */
+const getChatConversationList = async () => {
+  try {
+    // 加载中
+    loadingTime.value = setTimeout(() => {
+      loading.value = true
+    }, 50)
+
+    // 1.1 获取 对话数据
+    conversationList.value = await ChatConversationApi.getChatConversationMyList()
+    // 1.2 排序
+    conversationList.value.sort((a, b) => {
+      return b.createTime - a.createTime
+    })
+    // 1.3 没有任何对话情况
+    if (conversationList.value.length === 0) {
+      activeConversationId.value = null
+      conversationMap.value = {}
+      return
+    }
+
+    // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
+    conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
+  } finally {
+    // 清理定时器
+    if (loadingTime.value) {
+      clearTimeout(loadingTime.value)
+    }
+    // 加载完成
+    loading.value = false
+  }
+}
+
+/** 按照 creteTime 创建时间,进行分组 */
+const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => {
+  // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
+  // noinspection NonAsciiCharacters
+  const groupMap = {
+    置顶: [],
+    今天: [],
+    一天前: [],
+    三天前: [],
+    七天前: [],
+    三十天前: []
+  }
+  // 当前时间的时间戳
+  const now = Date.now()
+  // 定义时间间隔常量(单位:毫秒)
+  const oneDay = 24 * 60 * 60 * 1000
+  const threeDays = 3 * oneDay
+  const sevenDays = 7 * oneDay
+  const thirtyDays = 30 * oneDay
+  for (const conversation of list) {
+    // 置顶
+    if (conversation.pinned) {
+      groupMap['置顶'].push(conversation)
+      continue
+    }
+    // 计算时间差(单位:毫秒)
+    const diff = now - conversation.createTime
+    // 根据时间间隔判断
+    if (diff < oneDay) {
+      groupMap['今天'].push(conversation)
+    } else if (diff < threeDays) {
+      groupMap['一天前'].push(conversation)
+    } else if (diff < sevenDays) {
+      groupMap['三天前'].push(conversation)
+    } else if (diff < thirtyDays) {
+      groupMap['七天前'].push(conversation)
+    } else {
+      groupMap['三十天前'].push(conversation)
+    }
+  }
+  return groupMap
+}
+
+/** 新建对话 */
+const createConversation = async () => {
+  // 1. 新建对话
+  const conversationId = await ChatConversationApi.createChatConversationMy(
+    {} as unknown as ChatConversationVO
+  )
+  // 2. 获取对话内容
+  await getChatConversationList()
+  // 3. 选中对话
+  await handleConversationClick(conversationId)
+  // 4. 回调
+  emits('onConversationCreate')
+}
+
+/** 修改对话的标题 */
+const updateConversationTitle = async (conversation: ChatConversationVO) => {
+  // 1. 二次确认
+  const { value } = await ElMessageBox.prompt('修改标题', {
+    inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
+    inputErrorMessage: '标题不能为空',
+    inputValue: conversation.title
+  })
+  // 2. 发起修改
+  await ChatConversationApi.updateChatConversationMy({
+    id: conversation.id,
+    title: value
+  } as ChatConversationVO)
+  message.success('重命名成功')
+  // 3. 刷新列表
+  await getChatConversationList()
+  // 4. 过滤当前切换的
+  const filterConversationList = conversationList.value.filter((item) => {
+    return item.id === conversation.id
+  })
+  if (filterConversationList.length > 0) {
+    // tip:避免切换对话
+    if (activeConversationId.value === filterConversationList[0].id) {
+      emits('onConversationClick', filterConversationList[0])
+    }
+  }
+}
+
+/** 删除聊天对话 */
+const deleteChatConversation = async (conversation: ChatConversationVO) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`)
+    // 发起删除
+    await ChatConversationApi.deleteChatConversationMy(conversation.id)
+    message.success('对话已删除')
+    // 刷新列表
+    await getChatConversationList()
+    // 回调
+    emits('onConversationDelete', conversation)
+  } catch {}
+}
+
+/** 清空对话 */
+const handleClearConversation = async () => {
+  try {
+    await message.confirm('确认后对话会全部清空,置顶的对话除外。')
+    await ChatConversationApi.deleteChatConversationMyByUnpinned()
+    ElMessage({
+      message: '操作成功!',
+      type: 'success'
+    })
+    // 清空 对话 和 对话内容
+    activeConversationId.value = null
+    // 获取 对话列表
+    await getChatConversationList()
+    // 回调 方法
+    emits('onConversationClear')
+  } catch {}
+}
+
+/** 对话置顶 */
+const handleTop = async (conversation: ChatConversationVO) => {
+  // 更新对话置顶
+  conversation.pinned = !conversation.pinned
+  await ChatConversationApi.updateChatConversationMy(conversation)
+  // 刷新对话
+  await getChatConversationList()
+}
+
+// ============ 角色仓库 ============
+
+/** 角色仓库抽屉 */
+const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开
+const handleRoleRepository = async () => {
+  roleRepositoryOpen.value = !roleRepositoryOpen.value
+}
+
+/** 监听选中的对话 */
+const { activeId } = toRefs(props)
+watch(activeId, async (newValue, oldValue) => {
+  activeConversationId.value = newValue as string
+})
+
+// 定义 public 方法
+defineExpose({ createConversation })
+
+/** 初始化 */
+onMounted(async () => {
+  // 获取 对话列表
+  await getChatConversationList()
+  // 默认选中
+  if (props.activeId) {
+    activeConversationId.value = props.activeId
+  } else {
+    // 首次默认选中第一个
+    if (conversationList.value.length) {
+      activeConversationId.value = conversationList.value[0].id
+      // 回调 onConversationClick
+      await emits('onConversationClick', conversationList.value[0])
+    }
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.conversation-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 10px 10px 0;
+  overflow: hidden;
+
+  .btn-new-conversation {
+    padding: 18px 0;
+  }
+
+  .search-input {
+    margin-top: 20px;
+  }
+
+  .conversation-list {
+    overflow: auto;
+    height: 100%;
+
+    .classify-title {
+      padding-top: 10px;
+    }
+
+    .conversation-item {
+      margin-top: 5px;
+    }
+
+    .conversation {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      flex: 1;
+      padding: 0 5px;
+      cursor: pointer;
+      border-radius: 5px;
+      align-items: center;
+      line-height: 30px;
+
+      &.active {
+        background-color: #e6e6e6;
+
+        .button {
+          display: inline-block;
+        }
+      }
+
+      .title-wrapper {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+      }
+
+      .title {
+        padding: 2px 10px;
+        max-width: 220px;
+        font-size: 14px;
+        font-weight: 400;
+        color: rgba(0, 0, 0, 0.77);
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      .avatar {
+        width: 25px;
+        height: 25px;
+        border-radius: 5px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+      }
+
+      // 对话编辑、删除
+      .button-wrapper {
+        right: 2px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+        color: #606266;
+
+        .btn {
+          margin: 0;
+        }
+      }
+    }
+  }
+
+  // 角色仓库、清空未设置对话
+  .tool-box {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    //width: 100%;
+    padding: 0 20px;
+    background-color: #f4f4f4;
+    box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8);
+    line-height: 35px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: var(--el-text-color);
+
+    > div {
+      display: flex;
+      align-items: center;
+      color: #606266;
+      padding: 0;
+      margin: 0;
+      cursor: pointer;
+
+      > span {
+        margin-left: 5px;
+      }
+    }
+  }
+}
+</style>

+ 145 - 0
src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue

@@ -0,0 +1,145 @@
+<template>
+  <Dialog title="设定" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="130px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="角色设定" prop="systemMessage">
+        <el-input
+          type="textarea"
+          v-model="formData.systemMessage"
+          rows="4"
+          placeholder="请输入角色设定"
+        />
+      </el-form-item>
+      <el-form-item label="模型" prop="modelId">
+        <el-select v-model="formData.modelId" placeholder="请选择模型">
+          <el-option
+            v-for="chatModel in chatModelList"
+            :key="chatModel.id"
+            :label="chatModel.name"
+            :value="chatModel.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="温度参数" prop="temperature">
+        <el-input-number
+          v-model="formData.temperature"
+          placeholder="请输入温度参数"
+          :min="0"
+          :max="2"
+          :precision="2"
+        />
+      </el-form-item>
+      <el-form-item label="回复数 Token 数" prop="maxTokens">
+        <el-input-number
+          v-model="formData.maxTokens"
+          placeholder="请输入回复数 Token 数"
+          :min="0"
+          :max="4096"
+        />
+      </el-form-item>
+      <el-form-item label="上下文数量" prop="maxContexts">
+        <el-input-number
+          v-model="formData.maxContexts"
+          placeholder="请输入上下文数量"
+          :min="0"
+          :max="20"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { CommonStatusEnum } from '@/utils/constants'
+import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+
+/** AI 聊天对话的更新表单 */
+defineOptions({ name: 'ChatConversationUpdateForm' })
+
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  id: undefined,
+  systemMessage: undefined,
+  modelId: undefined,
+  temperature: undefined,
+  maxTokens: undefined,
+  maxContexts: undefined
+})
+const formRules = reactive({
+  modelId: [{ required: true, message: '模型不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }],
+  temperature: [{ required: true, message: '温度参数不能为空', trigger: 'blur' }],
+  maxTokens: [{ required: true, message: '回复数 Token 数不能为空', trigger: 'blur' }],
+  maxContexts: [{ required: true, message: '上下文数量不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
+
+/** 打开弹窗 */
+const open = async (id: number) => {
+  dialogVisible.value = true
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      const data = await ChatConversationApi.getChatConversationMy(id)
+      formData.value = Object.keys(formData.value).reduce((obj, key) => {
+        if (data.hasOwnProperty(key)) {
+          obj[key] = data[key]
+        }
+        return obj
+      }, {})
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得下拉数据
+  chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ChatConversationVO
+    await ChatConversationApi.updateChatConversationMy(data)
+    message.success('对话配置已更新')
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    systemMessage: undefined,
+    modelId: undefined,
+    temperature: undefined,
+    maxTokens: undefined,
+    maxContexts: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 282 - 0
src/views/ai/chat/index/components/message/MessageList.vue

@@ -0,0 +1,282 @@
+<template>
+  <div ref="messageContainer" class="h-100% overflow-y-auto relative">
+    <div class="chat-list" v-for="(item, index) in list" :key="index">
+      <!-- 靠左 message:system、assistant 类型 -->
+      <div class="left-message message-item" v-if="item.type !== 'user'">
+        <div class="avatar">
+          <el-avatar :src="roleAvatar" />
+        </div>
+        <div class="message">
+          <div>
+            <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
+          </div>
+          <div class="left-text-container" ref="markdownViewRef">
+            <MarkdownView class="left-text" :content="item.content" />
+          </div>
+          <div class="left-btns">
+            <el-button class="btn-cus" link @click="copyContent(item.content)">
+              <img class="btn-image" src="@/assets/ai/copy.svg" />
+            </el-button>
+            <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)">
+              <img class="btn-image h-17px" src="@/assets/ai/delete.svg" />
+            </el-button>
+          </div>
+        </div>
+      </div>
+      <!-- 靠右 message:user 类型 -->
+      <div class="right-message message-item" v-if="item.type === 'user'">
+        <div class="avatar">
+          <el-avatar :src="userAvatar" />
+        </div>
+        <div class="message">
+          <div>
+            <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
+          </div>
+          <div class="right-text-container">
+            <div class="right-text">{{ item.content }}</div>
+          </div>
+          <div class="right-btns">
+            <el-button class="btn-cus" link @click="copyContent(item.content)">
+              <img class="btn-image" src="@/assets/ai/copy.svg" />
+            </el-button>
+            <el-button class="btn-cus" link @click="onDelete(item.id)">
+              <img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" />
+            </el-button>
+            <el-button class="btn-cus" link @click="onRefresh(item)">
+              <el-icon size="17"><RefreshRight /></el-icon>
+            </el-button>
+            <el-button class="btn-cus" link @click="onEdit(item)">
+              <el-icon size="17"><Edit /></el-icon>
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+  <!-- 回到底部 -->
+  <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
+    <el-button :icon="ArrowDownBold" circle />
+  </div>
+</template>
+<script setup lang="ts">
+import { PropType } from 'vue'
+import { formatDate } from '@/utils/formatTime'
+import MarkdownView from '@/components/MarkdownView/index.vue'
+import { useClipboard } from '@vueuse/core'
+import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
+import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
+import { ChatConversationVO } from '@/api/ai/chat/conversation'
+import { useUserStore } from '@/store/modules/user'
+import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
+import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
+
+const message = useMessage() // 消息弹窗
+const { copy } = useClipboard() // 初始化 copy 到粘贴板
+const userStore = useUserStore()
+
+// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
+const messageContainer: any = ref(null)
+const isScrolling = ref(false) //用于判断用户是否在滚动
+
+const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
+const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
+
+// 定义 props
+const props = defineProps({
+  conversation: {
+    type: Object as PropType<ChatConversationVO>,
+    required: true
+  },
+  list: {
+    type: Array as PropType<ChatMessageVO[]>,
+    required: true
+  }
+})
+
+const { list } = toRefs(props) // 消息列表
+
+const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits
+
+// ============ 处理对话滚动 ==============
+
+/** 滚动到底部 */
+const scrollToBottom = async (isIgnore?: boolean) => {
+  // 注意要使用 nextTick 以免获取不到 dom
+  await nextTick()
+  if (isIgnore || !isScrolling.value) {
+    messageContainer.value.scrollTop =
+      messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
+  }
+}
+
+function handleScroll() {
+  const scrollContainer = messageContainer.value
+  const scrollTop = scrollContainer.scrollTop
+  const scrollHeight = scrollContainer.scrollHeight
+  const offsetHeight = scrollContainer.offsetHeight
+  if (scrollTop + offsetHeight < scrollHeight - 100) {
+    // 用户开始滚动并在最底部之上,取消保持在最底部的效果
+    isScrolling.value = true
+  } else {
+    // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
+    isScrolling.value = false
+  }
+}
+
+/** 回到底部 */
+const handleGoBottom = async () => {
+  const scrollContainer = messageContainer.value
+  scrollContainer.scrollTop = scrollContainer.scrollHeight
+}
+
+/** 回到顶部 */
+const handlerGoTop = async () => {
+  const scrollContainer = messageContainer.value
+  scrollContainer.scrollTop = 0
+}
+
+defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用
+
+// ============ 处理消息操作 ==============
+
+/** 复制 */
+const copyContent = async (content) => {
+  await copy(content)
+  message.success('复制成功!')
+}
+
+/** 删除 */
+const onDelete = async (id) => {
+  // 删除 message
+  await ChatMessageApi.deleteChatMessage(id)
+  message.success('删除成功!')
+  // 回调
+  emits('onDeleteSuccess')
+}
+
+/** 刷新 */
+const onRefresh = async (message: ChatMessageVO) => {
+  emits('onRefresh', message)
+}
+
+/** 编辑 */
+const onEdit = async (message: ChatMessageVO) => {
+  emits('onEdit', message)
+}
+
+/** 初始化 */
+onMounted(async () => {
+  messageContainer.value.addEventListener('scroll', handleScroll)
+})
+</script>
+
+<style scoped lang="scss">
+.message-container {
+  position: relative;
+  overflow-y: scroll;
+}
+
+// 中间
+.chat-list {
+  display: flex;
+  flex-direction: column;
+  overflow-y: hidden;
+  padding: 0 20px;
+  .message-item {
+    margin-top: 50px;
+  }
+
+  .left-message {
+    display: flex;
+    flex-direction: row;
+  }
+
+  .right-message {
+    display: flex;
+    flex-direction: row-reverse;
+    justify-content: flex-start;
+  }
+
+  .message {
+    display: flex;
+    flex-direction: column;
+    text-align: left;
+    margin: 0 15px;
+
+    .time {
+      text-align: left;
+      line-height: 30px;
+    }
+
+    .left-text-container {
+      position: relative;
+      display: flex;
+      flex-direction: column;
+      overflow-wrap: break-word;
+      background-color: rgba(228, 228, 228, 0.8);
+      box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8);
+      border-radius: 10px;
+      padding: 10px 10px 5px 10px;
+
+      .left-text {
+        color: #393939;
+        font-size: 0.95rem;
+      }
+    }
+
+    .right-text-container {
+      display: flex;
+      flex-direction: row-reverse;
+
+      .right-text {
+        font-size: 0.95rem;
+        color: #fff;
+        display: inline;
+        background-color: #267fff;
+        box-shadow: 0 0 0 1px #267fff;
+        border-radius: 10px;
+        padding: 10px;
+        width: auto;
+        overflow-wrap: break-word;
+        white-space: pre-wrap;
+      }
+    }
+
+    .left-btns {
+      display: flex;
+      flex-direction: row;
+      margin-top: 8px;
+    }
+
+    .right-btns {
+      display: flex;
+      flex-direction: row-reverse;
+      margin-top: 8px;
+    }
+  }
+
+  // 复制、删除按钮
+  .btn-cus {
+    display: flex;
+    background-color: transparent;
+    align-items: center;
+
+    .btn-image {
+      height: 20px;
+    }
+  }
+
+  .btn-cus:hover {
+    cursor: pointer;
+    background-color: #f6f6f6;
+  }
+}
+
+// 回到底部
+.to-bottom {
+  position: absolute;
+  z-index: 1000;
+  bottom: 0;
+  right: 50%;
+}
+</style>

+ 83 - 0
src/views/ai/chat/index/components/message/MessageListEmpty.vue

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

+ 15 - 0
src/views/ai/chat/index/components/message/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>

+ 46 - 0
src/views/ai/chat/index/components/message/MessageNewConversation.vue

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

+ 53 - 0
src/views/ai/chat/index/components/role/RoleCategoryList.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="category-list">
+    <div class="category" v-for="category in categoryList" :key="category">
+      <el-button
+        plain
+        round
+        size="small"
+        :type="category === active ? 'primary' : ''"
+        @click="handleCategoryClick(category)"
+      >
+        {{ category }}
+      </el-button>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { PropType } from 'vue'
+
+// 定义属性
+defineProps({
+  categoryList: {
+    type: Array as PropType<string[]>,
+    required: true
+  },
+  active: {
+    type: String,
+    required: false,
+    default: '全部'
+  }
+})
+
+// 定义回调
+const emits = defineEmits(['onCategoryClick'])
+
+/** 处理分类点击事件 */
+const handleCategoryClick = async (category: string) => {
+  emits('onCategoryClick', category)
+}
+</script>
+<style scoped lang="scss">
+.category-list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-items: center;
+
+  .category {
+    display: flex;
+    flex-direction: row;
+    margin-right: 10px;
+  }
+}
+</style>

+ 48 - 0
src/views/ai/chat/index/components/role/RoleHeader.vue

@@ -0,0 +1,48 @@
+<!-- header -->
+<template>
+  <el-header class="chat-header">
+    <div class="title">
+      {{ title }}
+    </div>
+    <div class="title-right">
+      <slot></slot>
+    </div>
+  </el-header>
+</template>
+
+<script setup lang="ts">
+// 设置组件属性
+defineProps({
+  title: {
+    type: String,
+    required: true
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.chat-header {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 10px;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  background-color: #ececec;
+  width: 100%;
+
+  .title {
+    font-size: 20px;
+    font-weight: bold;
+    overflow: hidden;
+    color: #3e3e3e;
+    max-width: 220px;
+  }
+
+  .title-right {
+    display: flex;
+    flex-direction: row;
+  }
+}
+</style>

+ 174 - 0
src/views/ai/chat/index/components/role/RoleList.vue

@@ -0,0 +1,174 @@
+<template>
+  <div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
+    <div class="card-item" v-for="role in roleList" :key="role.id">
+      <el-card class="card" body-class="card-body">
+        <!-- 更多操作 -->
+        <div class="more-container" v-if="showMore">
+          <el-dropdown @command="handleMoreClick">
+            <span class="el-dropdown-link">
+              <el-button type="text">
+                <el-icon><More /></el-icon>
+              </el-button>
+            </span>
+            <template #dropdown>
+              <el-dropdown-menu>
+                <el-dropdown-item :command="['edit', role]">
+                  <Icon icon="ep:edit" color="#787878" />编辑
+                </el-dropdown-item>
+                <el-dropdown-item :command="['delete', role]" style="color: red">
+                  <Icon icon="ep:delete" color="red" />删除
+                </el-dropdown-item>
+              </el-dropdown-menu>
+            </template>
+          </el-dropdown>
+        </div>
+        <!-- 角色信息 -->
+        <div>
+          <img class="avatar" :src="role.avatar" />
+        </div>
+        <div class="right-container">
+          <div class="content-container">
+            <div class="title">{{ role.name }}</div>
+            <div class="description">{{ role.description }}</div>
+          </div>
+          <div class="btn-container">
+            <el-button type="primary" size="small" @click="handleUseClick(role)">使用</el-button>
+          </div>
+        </div>
+      </el-card>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {ChatRoleVO} from '@/api/ai/model/chatRole'
+import {PropType, ref} from 'vue'
+import {More} from '@element-plus/icons-vue'
+
+const tabsRef = ref<any>() // tabs ref
+
+// 定义属性
+const props = defineProps({
+  loading: {
+    type: Boolean,
+    required: true
+  },
+  roleList: {
+    type: Array as PropType<ChatRoleVO[]>,
+    required: true
+  },
+  showMore: {
+    type: Boolean,
+    required: false,
+    default: false
+  }
+})
+
+// 定义钩子
+const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
+
+/** 操作:编辑、删除 */
+const handleMoreClick = async (data) => {
+  const type = data[0]
+  const role = data[1]
+  if (type === 'delete') {
+    emits('onDelete', role)
+  } else {
+    emits('onEdit', role)
+  }
+}
+
+/** 选中 */
+const handleUseClick = (role) => {
+  emits('onUse', role)
+}
+
+/** 滚动 */
+const handleTabsScroll = async () => {
+  if (tabsRef.value) {
+    const { scrollTop, scrollHeight, clientHeight } = tabsRef.value
+    if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
+      await emits('onPage')
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+// 重写 card 组件 body 样式
+.card-body {
+  max-width: 240px;
+  width: 240px;
+  padding: 15px 15px 10px 15px;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-start;
+  position: relative;
+}
+</style>
+<style scoped lang="scss">
+// 卡片列表
+.card-list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  position: relative;
+  height: 100%;
+  overflow: auto;
+  padding: 0px 25px;
+  padding-bottom: 140px;
+  align-items: start;
+  align-content: flex-start;
+  justify-content: start;
+
+  .card {
+    display: inline-block;
+    margin-right: 20px;
+    border-radius: 10px;
+    margin-bottom: 20px;
+    position: relative;
+
+    .more-container {
+      position: absolute;
+      top: 0;
+      right: 12px;
+    }
+
+    .avatar {
+      width: 40px;
+      height: 40px;
+      border-radius: 10px;
+      overflow: hidden;
+    }
+
+    .right-container {
+      margin-left: 10px;
+      width: 100%;
+      //height: 100px;
+
+      .content-container {
+        height: 85px;
+
+        .title {
+          font-size: 18px;
+          font-weight: bold;
+          color: #3e3e3e;
+        }
+
+        .description {
+          margin-top: 10px;
+          font-size: 14px;
+          color: #6a6a6a;
+        }
+      }
+
+      .btn-container {
+        display: flex;
+        flex-direction: row-reverse;
+        margin-top: 2px;
+      }
+    }
+  }
+}
+</style>

+ 289 - 0
src/views/ai/chat/index/components/role/RoleRepository.vue

@@ -0,0 +1,289 @@
+<!-- chat 角色仓库 -->
+<template>
+  <el-container class="role-container">
+    <ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
+    <!-- header  -->
+    <RoleHeader title="角色仓库" class="relative" />
+    <!--  main  -->
+    <el-main class="role-main">
+      <div class="search-container">
+        <!-- 搜索按钮 -->
+        <el-input
+          :loading="loading"
+          v-model="search"
+          class="search-input"
+          size="default"
+          placeholder="请输入搜索的内容"
+          :suffix-icon="Search"
+          @change="getActiveTabsRole"
+        />
+        <el-button
+          v-if="activeTab == 'my-role'"
+          type="primary"
+          @click="handlerAddRole"
+          class="ml-20px"
+        >
+          <Icon icon="ep:user" style="margin-right: 5px;" />
+          添加角色
+        </el-button>
+      </div>
+      <!-- tabs -->
+      <el-tabs v-model="activeTab" class="tabs" @tab-click="handleTabsClick">
+        <el-tab-pane class="role-pane" label="我的角色" name="my-role">
+          <RoleList
+            :loading="loading"
+            :role-list="myRoleList"
+            :show-more="true"
+            @on-delete="handlerCardDelete"
+            @on-edit="handlerCardEdit"
+            @on-use="handlerCardUse"
+            @on-page="handlerCardPage('my')"
+            class="mt-20px"
+          />
+        </el-tab-pane>
+        <el-tab-pane label="公共角色" name="public-role">
+          <RoleCategoryList
+            class="role-category-list"
+            :category-list="categoryList"
+            :active="activeCategory"
+            @on-category-click="handlerCategoryClick"
+          />
+          <RoleList
+            :role-list="publicRoleList"
+            @on-delete="handlerCardDelete"
+            @on-edit="handlerCardEdit"
+            @on-use="handlerCardUse"
+            @on-page="handlerCardPage('public')"
+            class="mt-20px"
+            loading
+          />
+        </el-tab-pane>
+      </el-tabs>
+    </el-main>
+  </el-container>
+</template>
+
+<script setup lang="ts">
+import {ref} from 'vue'
+import RoleHeader from './RoleHeader.vue'
+import RoleList from './RoleList.vue'
+import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
+import RoleCategoryList from './RoleCategoryList.vue'
+import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole'
+import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
+import {Search} from '@element-plus/icons-vue'
+import {TabsPaneContext} from 'element-plus'
+
+const router = useRouter() // 路由对象
+
+// 属性定义
+const loading = ref<boolean>(false) // 加载中
+const activeTab = ref<string>('my-role') // 选中的角色 Tab
+const search = ref<string>('') // 加载中
+const myRoleParams = reactive({
+  pageNo: 1,
+  pageSize: 50
+})
+const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小
+const publicRoleParams = reactive({
+  pageNo: 1,
+  pageSize: 50
+})
+const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小
+const activeCategory = ref<string>('全部') // 选择中的分类
+const categoryList = ref<string[]>([]) // 角色分类类别
+
+/** tabs 点击 */
+const handleTabsClick = async (tab: TabsPaneContext) => {
+  // 设置切换状态
+  activeTab.value = tab.paneName + ''
+  // 切换的时候重新加载数据
+  await getActiveTabsRole()
+}
+
+/** 获取 my role 我的角色 */
+const getMyRole = async (append?: boolean) => {
+  const params: ChatRolePageReqVO = {
+    ...myRoleParams,
+    name: search.value,
+    publicStatus: false
+  }
+  const { list } = await ChatRoleApi.getMyPage(params)
+  if (append) {
+    myRoleList.value.push.apply(myRoleList.value, list)
+  } else {
+    myRoleList.value = list
+  }
+}
+
+/** 获取 public role 公共角色 */
+const getPublicRole = async (append?: boolean) => {
+  const params: ChatRolePageReqVO = {
+    ...publicRoleParams,
+    category: activeCategory.value === '全部' ? '' : activeCategory.value,
+    name: search.value,
+    publicStatus: true
+  }
+  const { total, list } = await ChatRoleApi.getMyPage(params)
+  if (append) {
+    publicRoleList.value.push.apply(publicRoleList.value, list)
+  } else {
+    publicRoleList.value = list
+  }
+}
+
+/** 获取选中的 tabs 角色 */
+const getActiveTabsRole = async () => {
+  if (activeTab.value === 'my-role') {
+    myRoleParams.pageNo = 1
+    await getMyRole()
+  } else {
+    publicRoleParams.pageNo = 1
+    await getPublicRole()
+  }
+}
+
+/** 获取角色分类列表 */
+const getRoleCategoryList = async () => {
+  categoryList.value = ['全部', ...(await ChatRoleApi.getCategoryList())]
+}
+
+/** 处理分类点击 */
+const handlerCategoryClick = async (category: string) => {
+  // 切换选择的分类
+  activeCategory.value = category
+  // 筛选
+  await getActiveTabsRole()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const handlerAddRole = async () => {
+  formRef.value.open('my-create', null, '添加角色')
+}
+/** 编辑角色 */
+const handlerCardEdit = async (role) => {
+  formRef.value.open('my-update', role.id, '编辑角色')
+}
+
+/** 添加角色成功 */
+const handlerAddRoleSuccess = async (e) => {
+  // 刷新数据
+  await getActiveTabsRole()
+}
+
+/** 删除角色 */
+const handlerCardDelete = async (role) => {
+  await ChatRoleApi.deleteMy(role.id)
+  // 刷新数据
+  await getActiveTabsRole()
+}
+
+/** 角色分页:获取下一页 */
+const handlerCardPage = async (type) => {
+  try {
+    loading.value = true
+    if (type === 'public') {
+      publicRoleParams.pageNo++
+      await getPublicRole(true)
+    } else {
+      myRoleParams.pageNo++
+      await getMyRole(true)
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 选择 card 角色:新建聊天对话 */
+const handlerCardUse = async (role) => {
+  // 1. 创建对话
+  const data: ChatConversationVO = {
+    roleId: role.id
+  } as unknown as ChatConversationVO
+  const conversationId = await ChatConversationApi.createChatConversationMy(data)
+
+  // 2. 跳转页面
+  await router.push({
+    name: 'AiChat',
+    query: {
+      conversationId: conversationId
+    }
+  })
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  // 获取分类
+  await getRoleCategoryList()
+  // 获取 role 数据
+  await getActiveTabsRole()
+})
+</script>
+<!-- 覆盖 element ui css -->
+<style lang="scss">
+.el-tabs__content {
+  position: relative;
+  height: 100%;
+  overflow: hidden;
+}
+.el-tabs__nav-scroll {
+  margin: 10px 20px;
+}
+</style>
+<!-- 样式 -->
+<style scoped lang="scss">
+// 跟容器
+.role-container {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  margin: 0;
+  padding: 0;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  background-color: #ffffff;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  .role-main {
+    flex: 1;
+    overflow: hidden;
+    margin: 0;
+    padding: 0;
+    position: relative;
+
+    .search-container {
+      margin: 20px 20px 0px 20px;
+      position: absolute;
+      right: 0;
+      top: -5px;
+      z-index: 100;
+    }
+
+    .search-input {
+      width: 240px;
+    }
+
+    .tabs {
+      position: relative;
+      height: 100%;
+
+      .role-category-list {
+        margin: 0 27px;
+      }
+    }
+
+    .role-pane {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      overflow-y: auto;
+      position: relative;
+    }
+  }
+}
+</style>

+ 773 - 0
src/views/ai/chat/index/index.vue

@@ -0,0 +1,773 @@
+<template>
+  <el-container class="ai-layout">
+    <!-- 左侧:对话列表 -->
+    <ConversationList
+      :active-id="activeConversationId"
+      ref="conversationListRef"
+      @onConversationCreate="handleConversationCreateSuccess"
+      @onConversationClick="handleConversationClick"
+      @onConversationClear="handleConversationClear"
+      @onConversationDelete="handlerConversationDelete"
+    />
+    <!-- 右侧:对话详情 -->
+    <el-container class="detail-container">
+      <el-header class="header">
+        <div class="title">
+          {{ activeConversation?.title ? activeConversation?.title : '对话' }}
+          <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span>
+        </div>
+        <div class="btns" v-if="activeConversation">
+          <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
+            <span v-html="activeConversation?.modelName"></span>
+            <Icon icon="ep:setting" class="ml-10px" />
+          </el-button>
+          <el-button size="small" class="btn" @click="handlerMessageClear">
+            <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
+          </el-button>
+          <el-button size="small" class="btn">
+            <Icon icon="ep:download" color="#787878" />
+          </el-button>
+          <el-button size="small" class="btn" @click="handleGoTopMessage" >
+            <Icon icon="ep:top" color="#787878" />
+          </el-button>
+        </div>
+      </el-header>
+
+      <!-- main:消息列表 -->
+      <el-main class="main-container">
+        <div>
+          <div class="message-container">
+            <!-- 情况一:消息加载中 -->
+            <MessageLoading v-if="activeMessageListLoading" />
+            <!-- 情况二:无聊天对话时 -->
+            <MessageNewConversation
+              v-if="!activeConversation"
+              @on-new-conversation="handleConversationCreate"
+            />
+            <!-- 情况三:消息列表为空 -->
+            <MessageListEmpty
+              v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation"
+              @on-prompt="doSendMessage"
+            />
+            <!-- 情况四:消息列表不为空 -->
+            <MessageList
+              v-if="!activeMessageListLoading && messageList.length > 0"
+              ref="messageRef"
+              :conversation="activeConversation"
+              :list="messageList"
+              @on-delete-success="handleMessageDelete"
+              @on-edit="handleMessageEdit"
+              @on-refresh="handleMessageRefresh"
+            />
+          </div>
+        </div>
+      </el-main>
+
+      <!-- 底部 -->
+      <el-footer class="footer-container">
+        <form class="prompt-from">
+          <textarea
+            class="prompt-input"
+            v-model="prompt"
+            @keydown="handleSendByKeydown"
+            @input="handlePromptInput"
+            @compositionstart="onCompositionstart"
+            @compositionend="onCompositionend"
+            placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
+          ></textarea>
+          <div class="prompt-btns">
+            <div>
+              <el-switch v-model="enableContext" />
+              <span class="ml-5px text-14px text-#8f8f8f">上下文</span>
+            </div>
+            <el-button
+              type="primary"
+              size="default"
+              @click="handleSendByButton"
+              :loading="conversationInProgress"
+              v-if="conversationInProgress == false"
+            >
+              {{ conversationInProgress ? '进行中' : '发送' }}
+            </el-button>
+            <el-button
+              type="danger"
+              size="default"
+              @click="stopStream()"
+              v-if="conversationInProgress == true"
+            >
+              停止
+            </el-button>
+          </div>
+        </form>
+      </el-footer>
+    </el-container>
+
+    <!-- 更新对话 Form -->
+    <ConversationUpdateForm
+      ref="conversationUpdateFormRef"
+      @success="handleConversationUpdateSuccess"
+    />
+  </el-container>
+</template>
+
+<script setup lang="ts">
+import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import ConversationList from './components/conversation/ConversationList.vue'
+import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue'
+import MessageList from './components/message/MessageList.vue'
+import MessageListEmpty from './components/message/MessageListEmpty.vue'
+import MessageLoading from './components/message/MessageLoading.vue'
+import MessageNewConversation from './components/message/MessageNewConversation.vue'
+import { Download, Top } from '@element-plus/icons-vue'
+
+/** AI 聊天对话 列表 */
+defineOptions({ name: 'AiChat' })
+
+const route = useRoute() // 路由
+const message = useMessage() // 消息弹窗
+
+// 聊天对话
+const conversationListRef = ref()
+const activeConversationId = ref<number | null>(null) // 选中的对话编号
+const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
+const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
+
+// 消息列表
+const messageRef = ref()
+const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表
+const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中
+const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
+// 消息滚动
+const textSpeed = ref<number>(50) // Typing speed in milliseconds
+const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
+
+// 发送消息输入框
+const isComposing = ref(false) // 判断用户是否在输入
+const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
+const inputTimeout = ref<any>() // 处理输入中回车的定时器
+const prompt = ref<string>() // prompt
+const enableContext = ref<boolean>(true) // 是否开启上下文
+// 接收 Stream 消息
+const receiveMessageFullText = ref('')
+const receiveMessageDisplayedText = ref('')
+
+// =========== 【聊天对话】相关 ===========
+
+/** 获取对话信息 */
+const getConversation = async (id: number | null) => {
+  if (!id) {
+    return
+  }
+  const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
+  if (!conversation) {
+    return
+  }
+  activeConversation.value = conversation
+  activeConversationId.value = conversation.id
+}
+
+/**
+ * 点击某个对话
+ *
+ * @param conversation 选中的对话
+ * @return 是否切换成功
+ */
+const handleConversationClick = async (conversation: ChatConversationVO) => {
+  // 对话进行中,不允许切换
+  if (conversationInProgress.value) {
+    message.alert('对话中,不允许切换!')
+    return false
+  }
+
+  // 更新选中的对话 id
+  activeConversationId.value = conversation.id
+  activeConversation.value = conversation
+  // 刷新 message 列表
+  await getMessageList()
+  // 滚动底部
+  scrollToBottom(true)
+  // 清空输入框
+  prompt.value = ''
+  return true
+}
+
+/** 删除某个对话*/
+const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
+  // 删除的对话如果是当前选中的,那么就重置
+  if (activeConversationId.value === delConversation.id) {
+    await handleConversationClear()
+  }
+}
+/** 清空选中的对话 */
+const handleConversationClear = async () => {
+  // 对话进行中,不允许切换
+  if (conversationInProgress.value) {
+    message.alert('对话中,不允许切换!')
+    return false
+  }
+  activeConversationId.value = null
+  activeConversation.value = null
+  activeMessageList.value = []
+}
+
+/** 修改聊天对话 */
+const conversationUpdateFormRef = ref()
+const openChatConversationUpdateForm = async () => {
+  conversationUpdateFormRef.value.open(activeConversationId.value)
+}
+const handleConversationUpdateSuccess = async () => {
+  // 对话更新成功,刷新最新信息
+  await getConversation(activeConversationId.value)
+}
+
+/** 处理聊天对话的创建成功 */
+const handleConversationCreate = async () => {
+  // 创建对话
+  await conversationListRef.value.createConversation()
+}
+/** 处理聊天对话的创建成功 */
+const handleConversationCreateSuccess = async () => {
+  // 创建新的对话,清空输入框
+  prompt.value = ''
+}
+
+// =========== 【消息列表】相关 ===========
+
+/** 获取消息 message 列表 */
+const getMessageList = async () => {
+  try {
+    if (activeConversationId.value === null) {
+      return
+    }
+    // Timer 定时器,如果加载速度很快,就不进入加载中
+    activeMessageListLoadingTimer.value = setTimeout(() => {
+      activeMessageListLoading.value = true
+    }, 60)
+
+    // 获取消息列表
+    activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
+      activeConversationId.value
+    )
+
+    // 滚动到最下面
+    await nextTick()
+    await scrollToBottom()
+  } finally {
+    // time 定时器,如果加载速度很快,就不进入加载中
+    if (activeMessageListLoadingTimer.value) {
+      clearTimeout(activeMessageListLoadingTimer.value)
+    }
+    // 加载结束
+    activeMessageListLoading.value = false
+  }
+}
+
+/**
+ * 消息列表
+ *
+ * 和 {@link #getMessageList()} 的差异是,把 systemMessage 考虑进去
+ */
+const messageList = computed(() => {
+  if (activeMessageList.value.length > 0) {
+    return activeMessageList.value
+  }
+  // 没有消息时,如果有 systemMessage 则展示它
+  if (activeConversation.value?.systemMessage) {
+    return [
+      {
+        id: 0,
+        type: 'system',
+        content: activeConversation.value.systemMessage
+      }
+    ]
+  }
+  return []
+})
+
+/** 处理删除 message 消息 */
+const handleMessageDelete = () => {
+  if (conversationInProgress.value) {
+    message.alert('回答中,不能删除!')
+    return
+  }
+  // 刷新 message 列表
+  getMessageList()
+}
+
+/** 处理 message 清空 */
+const handlerMessageClear = async () => {
+  if (!activeConversationId.value) {
+    return
+  }
+  try {
+    // 确认提示
+    await message.delConfirm('确认清空对话消息?')
+    // 清空对话
+    await ChatMessageApi.deleteByConversationId(activeConversationId.value)
+    // 刷新 message 列表
+    activeMessageList.value = []
+  } catch {}
+}
+
+/** 回到 message 列表的顶部 */
+const handleGoTopMessage = () => {
+  messageRef.value.handlerGoTop()
+}
+
+// =========== 【发送消息】相关 ===========
+
+/** 处理来自 keydown 的发送消息 */
+const handleSendByKeydown = async (event) => {
+  // 判断用户是否在输入
+  if (isComposing.value) {
+    return
+  }
+  // 进行中不允许发送
+  if (conversationInProgress.value) {
+    return
+  }
+  const content = prompt.value?.trim() as string
+  if (event.key === 'Enter') {
+    if (event.shiftKey) {
+      // 插入换行
+      prompt.value += '\r\n'
+      event.preventDefault() // 防止默认的换行行为
+    } else {
+      // 发送消息
+      await doSendMessage(content)
+      event.preventDefault() // 防止默认的提交行为
+    }
+  }
+}
+
+/** 处理来自【发送】按钮的发送消息 */
+const handleSendByButton = () => {
+  doSendMessage(prompt.value?.trim() as string)
+}
+
+/** 处理 prompt 输入变化 */
+const handlePromptInput = (event) => {
+  // 非输入法 输入设置为 true
+  if (!isComposing.value) {
+    // 回车 event data 是 null
+    if (event.data == null) {
+      return
+    }
+    isComposing.value = true
+  }
+  // 清理定时器
+  if (inputTimeout.value) {
+    clearTimeout(inputTimeout.value)
+  }
+  // 重置定时器
+  inputTimeout.value = setTimeout(() => {
+    isComposing.value = false
+  }, 400)
+}
+// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
+const onCompositionstart = () => {
+  isComposing.value = true
+}
+const onCompositionend = () => {
+  // console.log('输入结束...')
+  setTimeout(() => {
+    isComposing.value = false
+  }, 200)
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessage = async (content: string) => {
+  // 校验
+  if (content.length < 1) {
+    message.error('发送失败,原因:内容为空!')
+    return
+  }
+  if (activeConversationId.value == null) {
+    message.error('还没创建对话,不能发送!')
+    return
+  }
+  // 清空输入框
+  prompt.value = ''
+  // 执行发送
+  await doSendMessageStream({
+    conversationId: activeConversationId.value,
+    content: content
+  } as ChatMessageVO)
+}
+
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async (userMessage: ChatMessageVO) => {
+  // 创建 AbortController 实例,以便中止请求
+  conversationInAbortController.value = new AbortController()
+  // 标记对话进行中
+  conversationInProgress.value = true
+  // 设置为空
+  receiveMessageFullText.value = ''
+
+  try {
+    // 1.1 先添加两个假数据,等 stream 返回再替换
+    activeMessageList.value.push({
+      id: -1,
+      conversationId: activeConversationId.value,
+      type: 'user',
+      content: userMessage.content,
+      createTime: new Date()
+    } as ChatMessageVO)
+    activeMessageList.value.push({
+      id: -2,
+      conversationId: activeConversationId.value,
+      type: 'assistant',
+      content: '思考中...',
+      createTime: new Date()
+    } as ChatMessageVO)
+    // 1.2 滚动到最下面
+    await nextTick()
+    await scrollToBottom() // 底部
+    // 1.3 开始滚动
+    textRoll()
+
+    // 2. 发送 event stream
+    let isFirstChunk = true // 是否是第一个 chunk 消息段
+    await ChatMessageApi.sendChatMessageStream(
+      userMessage.conversationId,
+      userMessage.content,
+      conversationInAbortController.value,
+      enableContext.value,
+      async (res) => {
+        const { code, data, msg } = JSON.parse(res.data)
+        if (code !== 0) {
+          message.alert(`对话异常! ${msg}`)
+          return
+        }
+
+        // 如果内容为空,就不处理。
+        if (data.receive.content === '') {
+          return
+        }
+        // 首次返回需要添加一个 message 到页面,后面的都是更新
+        if (isFirstChunk) {
+          isFirstChunk = false
+          // 弹出两个假数据
+          activeMessageList.value.pop()
+          activeMessageList.value.pop()
+          // 更新返回的数据
+          activeMessageList.value.push(data.send)
+          activeMessageList.value.push(data.receive)
+        }
+        // debugger
+        receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
+        // 滚动到最下面
+        await scrollToBottom()
+      },
+      (error) => {
+        message.alert(`对话异常! ${error}`)
+        stopStream()
+      },
+      () => {
+        stopStream()
+      }
+    )
+  } catch {}
+}
+
+/** 停止 stream 流式调用 */
+const stopStream = async () => {
+  // tip:如果 stream 进行中的 message,就需要调用 controller 结束
+  if (conversationInAbortController.value) {
+    conversationInAbortController.value.abort()
+  }
+  // 设置为 false
+  conversationInProgress.value = false
+}
+
+/** 编辑 message:设置为 prompt,可以再次编辑 */
+const handleMessageEdit = (message: ChatMessageVO) => {
+  prompt.value = message.content
+}
+
+/** 刷新 message:基于指定消息,再次发起对话 */
+const handleMessageRefresh = (message: ChatMessageVO) => {
+  doSendMessage(message.content)
+}
+
+// ============== 【消息滚动】相关 =============
+
+/** 滚动到 message 底部 */
+const scrollToBottom = async (isIgnore?: boolean) => {
+  await nextTick()
+  if (messageRef.value) {
+    messageRef.value.scrollToBottom(isIgnore)
+  }
+}
+
+/** 自提滚动效果 */
+const textRoll = async () => {
+  let index = 0
+  try {
+    // 只能执行一次
+    if (textRoleRunning.value) {
+      return
+    }
+    // 设置状态
+    textRoleRunning.value = true
+    receiveMessageDisplayedText.value = ''
+    const task = async () => {
+      // 调整速度
+      const diff =
+        (receiveMessageFullText.value.length - receiveMessageDisplayedText.value.length) / 10
+      if (diff > 5) {
+        textSpeed.value = 10
+      } else if (diff > 2) {
+        textSpeed.value = 30
+      } else if (diff > 1.5) {
+        textSpeed.value = 50
+      } else {
+        textSpeed.value = 100
+      }
+      // 对话结束,就按 30 的速度
+      if (!conversationInProgress.value) {
+        textSpeed.value = 10
+      }
+
+      if (index < receiveMessageFullText.value.length) {
+        receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
+        index++
+
+        // 更新 message
+        const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
+        lastMessage.content = receiveMessageDisplayedText.value
+        // 滚动到住下面
+        await scrollToBottom()
+        // 重新设置任务
+        timer = setTimeout(task, textSpeed.value)
+      } else {
+        // 不是对话中可以结束
+        if (!conversationInProgress.value) {
+          textRoleRunning.value = false
+          clearTimeout(timer)
+        } else {
+          // 重新设置任务
+          timer = setTimeout(task, textSpeed.value)
+        }
+      }
+    }
+    let timer = setTimeout(task, textSpeed.value)
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  // 如果有 conversationId 参数,则默认选中
+  if (route.query.conversationId) {
+    const id = route.query.conversationId as unknown as number
+    activeConversationId.value = id
+    await getConversation(id)
+  }
+
+  // 获取列表数据
+  activeMessageListLoading.value = true
+  await getMessageList()
+})
+</script>
+
+<style lang="scss" scoped>
+.ai-layout {
+  position: absolute;
+  flex: 1;
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 100%;
+}
+
+.conversation-container {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  padding: 10px 10px 0;
+
+  .btn-new-conversation {
+    padding: 18px 0;
+  }
+
+  .search-input {
+    margin-top: 20px;
+  }
+
+  .conversation-list {
+    margin-top: 20px;
+
+    .conversation {
+      display: flex;
+      flex-direction: row;
+      justify-content: space-between;
+      flex: 1;
+      padding: 0 5px;
+      margin-top: 10px;
+      cursor: pointer;
+      border-radius: 5px;
+      align-items: center;
+      line-height: 30px;
+
+      &.active {
+        background-color: #e6e6e6;
+
+        .button {
+          display: inline-block;
+        }
+      }
+
+      .title-wrapper {
+        display: flex;
+        flex-direction: row;
+        align-items: center;
+      }
+
+      .title {
+        padding: 5px 10px;
+        max-width: 220px;
+        font-size: 14px;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+      }
+
+      .avatar {
+        width: 28px;
+        height: 28px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+      }
+
+      // 对话编辑、删除
+      .button-wrapper {
+        right: 2px;
+        display: flex;
+        flex-direction: row;
+        justify-items: center;
+        color: #606266;
+
+        .el-icon {
+          margin-right: 5px;
+        }
+      }
+    }
+  }
+
+  // 角色仓库、清空未设置对话
+  .tool-box {
+    line-height: 35px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: var(--el-text-color);
+
+    > div {
+      display: flex;
+      align-items: center;
+      color: #606266;
+      padding: 0;
+      margin: 0;
+      cursor: pointer;
+
+      > span {
+        margin-left: 5px;
+      }
+    }
+  }
+}
+
+// 头部
+.detail-container {
+  background: #ffffff;
+
+  .header {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-between;
+    background: #fbfbfb;
+    box-shadow: 0 0 0 0 #dcdfe6;
+
+    .title {
+      font-size: 18px;
+      font-weight: bold;
+    }
+
+    .btns {
+      display: flex;
+      width: 300px;
+      flex-direction: row;
+      justify-content: flex-end;
+      //justify-content: space-between;
+
+      .btn {
+        padding: 10px;
+      }
+    }
+  }
+}
+
+// main 容器
+.main-container {
+  margin: 0;
+  padding: 0;
+  position: relative;
+  height: 100%;
+  width: 100%;
+
+  .message-container {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    overflow-y: hidden;
+    padding: 0;
+    margin: 0;
+  }
+}
+
+// 底部
+.footer-container {
+  display: flex;
+  flex-direction: column;
+  height: auto;
+  margin: 0;
+  padding: 0;
+
+  .prompt-from {
+    display: flex;
+    flex-direction: column;
+    height: auto;
+    border: 1px solid #e3e3e3;
+    border-radius: 10px;
+    margin: 10px 20px 20px 20px;
+    padding: 9px 10px;
+  }
+
+  .prompt-input {
+    height: 80px;
+    //box-shadow: none;
+    border: none;
+    box-sizing: border-box;
+    resize: none;
+    padding: 0 2px;
+    overflow: auto;
+  }
+
+  .prompt-input:focus {
+    outline: none;
+  }
+
+  .prompt-btns {
+    display: flex;
+    justify-content: space-between;
+    padding-bottom: 0;
+    padding-top: 5px;
+  }
+}
+</style>

+ 163 - 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>

+ 162 - 0
src/views/ai/image/index/components/ImageCard.vue

@@ -0,0 +1,162 @@
+<template>
+  <el-card body-class="" class="image-card">
+    <div class="image-operation">
+      <div>
+        <el-button type="primary" text bg v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS">
+          生成中
+        </el-button>
+        <el-button text bg v-else-if="detail?.status === AiImageStatusEnum.SUCCESS">
+          已完成
+        </el-button>
+        <el-button type="danger" text bg v-else-if="detail?.status === AiImageStatusEnum.FAIL">
+          异常
+        </el-button>
+      </div>
+      <!-- 操作区 -->
+      <div>
+        <el-button
+          class="btn"
+          text
+          :icon="Download"
+          @click="handleButtonClick('download', detail)"
+        />
+        <el-button
+          class="btn"
+          text
+          :icon="RefreshRight"
+          @click="handleButtonClick('regeneration', detail)"
+        />
+        <el-button class="btn" text :icon="Delete" @click="handleButtonClick('delete', detail)" />
+        <el-button class="btn" text :icon="More" @click="handleButtonClick('more', detail)" />
+      </div>
+    </div>
+    <div class="image-wrapper" ref="cardImageRef">
+      <el-image
+        class="image"
+        :src="detail?.picUrl"
+        :preview-src-list="[detail.picUrl]"
+        preview-teleported
+      />
+      <div v-if="detail?.status === AiImageStatusEnum.FAIL">
+        {{ detail?.errorMessage }}
+      </div>
+    </div>
+    <!-- Midjourney 专属操作 -->
+    <div class="image-mj-btns">
+      <el-button
+        size="small"
+        v-for="button in detail?.buttons"
+        :key="button"
+        class="min-w-40px ml-0 mr-10px mt-5px"
+        @click="handleMidjourneyBtnClick(button)"
+      >
+        {{ button.label }}{{ button.emoji }}
+      </el-button>
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { Delete, Download, More, RefreshRight } from '@element-plus/icons-vue'
+import { ImageVO, ImageMidjourneyButtonsVO } from '@/api/ai/image'
+import { PropType } from 'vue'
+import { ElLoading, LoadingOptionsResolved } from 'element-plus'
+import { AiImageStatusEnum } from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息
+
+const props = defineProps({
+  detail: {
+    type: Object as PropType<ImageVO>,
+    require: true
+  }
+})
+
+const cardImageRef = ref<any>() // 卡片 image ref
+const cardImageLoadingInstance = ref<any>() // 卡片 image ref
+
+/** 处理点击事件  */
+const handleButtonClick = async (type, detail: ImageVO) => {
+  emits('onBtnClick', type, detail)
+}
+
+/** 处理 Midjourney 按钮点击事件  */
+const handleMidjourneyBtnClick = async (button: ImageMidjourneyButtonsVO) => {
+  // 确认窗体
+  await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`)
+  emits('onMjBtnClick', button, props.detail)
+}
+
+const emits = defineEmits(['onBtnClick', 'onMjBtnClick']) // emits
+
+/** 监听详情 */
+const { detail } = toRefs(props)
+watch(detail, async (newVal, oldVal) => {
+  await handleLoading(newVal.status as string)
+})
+
+/** 处理加载状态 */
+const handleLoading = async (status: number) => {
+  // 情况一:如果是生成中,则设置加载中的 loading
+  if (status === AiImageStatusEnum.IN_PROGRESS) {
+    cardImageLoadingInstance.value = ElLoading.service({
+      target: cardImageRef.value,
+      text: '生成中...'
+    } as LoadingOptionsResolved)
+    // 情况二:如果已经生成结束,则移除 loading
+  } else {
+    if (cardImageLoadingInstance.value) {
+      cardImageLoadingInstance.value.close()
+      cardImageLoadingInstance.value = null
+    }
+  }
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await handleLoading(props.detail.status as string)
+})
+</script>
+
+<style scoped lang="scss">
+.image-card {
+  width: 320px;
+  height: auto;
+  border-radius: 10px;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+
+  .image-operation {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+
+    .btn {
+      //border: 1px solid red;
+      padding: 10px;
+      margin: 0;
+    }
+  }
+
+  .image-wrapper {
+    overflow: hidden;
+    margin-top: 20px;
+    height: 280px;
+    flex: 1;
+
+    .image {
+      width: 100%;
+      border-radius: 10px;
+    }
+  }
+
+  .image-mj-btns {
+    margin-top: 5px;
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: flex-start;
+  }
+}
+</style>

+ 224 - 0
src/views/ai/image/index/components/ImageDetail.vue

@@ -0,0 +1,224 @@
+<template>
+  <el-drawer
+    v-model="showDrawer"
+    title="图片详细"
+    @close="handleDrawerClose"
+    custom-class="drawer-class"
+  >
+    <!-- 图片 -->
+    <div class="item">
+      <div class="body">
+        <el-image
+          class="image"
+          :src="detail?.picUrl"
+          :preview-src-list="[detail.picUrl]"
+          preview-teleported
+        />
+      </div>
+    </div>
+    <!-- 时间 -->
+    <div class="item">
+      <div class="tip">时间</div>
+      <div class="body">
+        <div>提交时间:{{ formatTime(detail.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
+        <div>生成时间:{{ formatTime(detail.finishTime, 'yyyy-MM-dd HH:mm:ss') }}</div>
+      </div>
+    </div>
+    <!-- 模型 -->
+    <div class="item">
+      <div class="tip">模型</div>
+      <div class="body"> {{ detail.model }}({{ detail.height }}x{{ detail.width }}) </div>
+    </div>
+    <!-- 提示词 -->
+    <div class="item">
+      <div class="tip">提示词</div>
+      <div class="body">
+        {{ detail.prompt }}
+      </div>
+    </div>
+    <!-- 地址 -->
+    <div class="item">
+      <div class="tip">图片地址</div>
+      <div class="body">
+        {{ detail.picUrl }}
+      </div>
+    </div>
+    <!-- StableDiffusion 专属区域 -->
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.sampler"
+    >
+      <div class="tip">采样方法</div>
+      <div class="body">
+        {{
+          StableDiffusionSamplers.find(
+            (item: ImageModelVO) => item.key === detail?.options?.sampler
+          )?.name
+        }}
+      </div>
+    </div>
+    <div
+      class="item"
+      v-if="
+        detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.clipGuidancePreset
+      "
+    >
+      <div class="tip">CLIP</div>
+      <div class="body">
+        {{
+          StableDiffusionClipGuidancePresets.find(
+            (item: ImageModelVO) => item.key === detail?.options?.clipGuidancePreset
+          )?.name
+        }}
+      </div>
+    </div>
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.stylePreset"
+    >
+      <div class="tip">风格</div>
+      <div class="body">
+        {{
+          StableDiffusionStylePresets.find(
+            (item: ImageModelVO) => item.key === detail?.options?.stylePreset
+          )?.name
+        }}
+      </div>
+    </div>
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.steps"
+    >
+      <div class="tip">迭代步数</div>
+      <div class="body">
+        {{ detail?.options?.steps }}
+      </div>
+    </div>
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.scale"
+    >
+      <div class="tip">引导系数</div>
+      <div class="body">
+        {{ detail?.options?.scale }}
+      </div>
+    </div>
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.STABLE_DIFFUSION && detail?.options?.seed"
+    >
+      <div class="tip">随机因子</div>
+      <div class="body">
+        {{ detail?.options?.seed }}
+      </div>
+    </div>
+    <!-- Dall3 专属区域 -->
+    <div class="item" v-if="detail.platform === AiPlatformEnum.OPENAI && detail?.options?.style">
+      <div class="tip">风格选择</div>
+      <div class="body">
+        {{ Dall3StyleList.find((item: ImageModelVO) => item.key === detail?.options?.style)?.name }}
+      </div>
+    </div>
+    <!-- Midjourney 专属区域 -->
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.version"
+    >
+      <div class="tip">模型版本</div>
+      <div class="body">
+        {{ detail?.options?.version }}
+      </div>
+    </div>
+    <div
+      class="item"
+      v-if="detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.referImageUrl"
+    >
+      <div class="tip">参考图</div>
+      <div class="body">
+        <el-image :src="detail.options.referImageUrl" />
+      </div>
+    </div>
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { ImageApi, ImageVO } from '@/api/ai/image'
+import {
+  AiPlatformEnum,
+  Dall3StyleList,
+  ImageModelVO,
+  StableDiffusionClipGuidancePresets,
+  StableDiffusionSamplers,
+  StableDiffusionStylePresets
+} from '@/views/ai/utils/constants'
+import { formatTime } from '@/utils'
+
+const showDrawer = ref<boolean>(false) // 是否显示
+const detail = ref<ImageVO>({} as ImageVO) // 图片详细信息
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    require: true,
+    default: false
+  },
+  id: {
+    type: Number,
+    required: true
+  }
+})
+
+/** 关闭抽屉  */
+const handleDrawerClose = async () => {
+  emits('handleDrawerClose')
+}
+
+/** 监听 drawer 是否打开 */
+const { show } = toRefs(props)
+watch(show, async (newValue, oldValue) => {
+  showDrawer.value = newValue as boolean
+})
+
+/**  获取图片详情  */
+const getImageDetail = async (id: number) => {
+  detail.value = await ImageApi.getImageMy(id)
+}
+
+/** 监听 id 变化,加载最新图片详情 */
+const { id } = toRefs(props)
+watch(id, async (newVal, oldVal) => {
+  if (newVal) {
+    await getImageDetail(newVal)
+  }
+})
+
+const emits = defineEmits(['handleDrawerClose'])
+</script>
+<style scoped lang="scss">
+.item {
+  margin-bottom: 20px;
+  width: 100%;
+  overflow: hidden;
+  word-wrap: break-word;
+
+  .header {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+  }
+
+  .tip {
+    font-weight: bold;
+    font-size: 16px;
+  }
+
+  .body {
+    margin-top: 10px;
+    color: #616161;
+
+    .taskImage {
+      border-radius: 10px;
+    }
+  }
+}
+</style>

+ 233 - 0
src/views/ai/image/index/components/ImageList.vue

@@ -0,0 +1,233 @@
+<template>
+  <el-card class="dr-task" body-class="task-card" shadow="never">
+    <template #header>绘画任务</template>
+    <!-- 图片列表 -->
+    <div class="task-image-list" ref="imageListRef">
+      <ImageCard
+        v-for="image in imageList"
+        :key="image.id"
+        :detail="image"
+        @on-btn-click="handleImageButtonClick"
+        @on-mj-btn-click="handleImageMidjourneyButtonClick"
+      />
+    </div>
+    <div class="task-image-pagination">
+      <Pagination
+        :total="pageTotal"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getImageList"
+      />
+    </div>
+  </el-card>
+
+  <!-- 图片详情 -->
+  <ImageDetail
+    :show="isShowImageDetail"
+    :id="showImageDetailId"
+    @handle-drawer-close="handleDetailClose"
+  />
+</template>
+<script setup lang="ts">
+import {
+  ImageApi,
+  ImageVO,
+  ImageMidjourneyActionVO,
+  ImageMidjourneyButtonsVO
+} from '@/api/ai/image'
+import ImageDetail from './ImageDetail.vue'
+import ImageCard from './ImageCard.vue'
+import { ElLoading, LoadingOptionsResolved } from 'element-plus'
+import { AiImageStatusEnum } from '@/views/ai/utils/constants'
+import download from '@/utils/download'
+
+const message = useMessage() // 消息弹窗
+
+// 图片分页相关的参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10
+})
+const pageTotal = ref<number>(0) // page size
+const imageList = ref<ImageVO[]>([]) // image 列表
+const imageListLoadingInstance = ref<any>() // image 列表是否正在加载中
+const imageListRef = ref<any>() // ref
+// 图片轮询相关的参数(正在生成中的)
+const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
+const inProgressTimer = ref<any>() // 生成中的 image 定时器,轮询生成进展
+// 图片详情相关的参数
+const isShowImageDetail = ref<boolean>(false) // 图片详情是否展示
+const showImageDetailId = ref<number>(0) // 图片详情的图片编号
+
+/** 查看图片的详情  */
+const handleDetailOpen = async () => {
+  isShowImageDetail.value = true
+}
+
+/** 关闭图片的详情  */
+const handleDetailClose = async () => {
+  isShowImageDetail.value = false
+}
+
+/** 获得 image 图片列表 */
+const getImageList = async () => {
+  try {
+    // 1. 加载图片列表
+    imageListLoadingInstance.value = ElLoading.service({
+      target: imageListRef.value,
+      text: '加载中...'
+    } as LoadingOptionsResolved)
+    const { list, total } = await ImageApi.getImagePageMy(queryParams)
+    imageList.value = list
+    pageTotal.value = total
+
+    // 2. 计算需要轮询的图片
+    const newWatImages = {}
+    imageList.value.forEach((item) => {
+      if (item.status === AiImageStatusEnum.IN_PROGRESS) {
+        newWatImages[item.id] = item
+      }
+    })
+    inProgressImageMap.value = newWatImages
+  } finally {
+    // 关闭正在“加载中”的 Loading
+    if (imageListLoadingInstance.value) {
+      imageListLoadingInstance.value.close()
+      imageListLoadingInstance.value = null
+    }
+  }
+}
+
+/** 轮询生成中的 image 列表 */
+const refreshWatchImages = async () => {
+  const imageIds = Object.keys(inProgressImageMap.value).map(Number)
+  if (imageIds.length == 0) {
+    return
+  }
+  const list = (await ImageApi.getImageListMyByIds(imageIds)) as ImageVO[]
+  const newWatchImages = {}
+  list.forEach((image) => {
+    if (image.status === AiImageStatusEnum.IN_PROGRESS) {
+      newWatchImages[image.id] = image
+    } else {
+      const index = imageList.value.findIndex((oldImage) => image.id === oldImage.id)
+      if (index >= 0) {
+        // 更新 imageList
+        imageList.value[index] = image
+      }
+    }
+  })
+  inProgressImageMap.value = newWatchImages
+}
+
+/** 图片的点击事件 */
+const handleImageButtonClick = async (type: string, imageDetail: ImageVO) => {
+  // 详情
+  if (type === 'more') {
+    showImageDetailId.value = imageDetail.id
+    await handleDetailOpen()
+    return
+  }
+  // 删除
+  if (type === 'delete') {
+    await message.confirm(`是否删除照片?`)
+    await ImageApi.deleteImageMy(imageDetail.id)
+    await getImageList()
+    message.success('删除成功!')
+    return
+  }
+  // 下载
+  if (type === 'download') {
+    await download.image(imageDetail.picUrl)
+    return
+  }
+  // 重新生成
+  if (type === 'regeneration') {
+    await emits('onRegeneration', imageDetail)
+    return
+  }
+}
+
+/** 处理 Midjourney 按钮点击事件  */
+const handleImageMidjourneyButtonClick = async (
+  button: ImageMidjourneyButtonsVO,
+  imageDetail: ImageVO
+) => {
+  // 1. 构建 params 参数
+  const data = {
+    id: imageDetail.id,
+    customId: button.customId
+  } as ImageMidjourneyActionVO
+  // 2. 发送 action
+  await ImageApi.midjourneyAction(data)
+  // 3. 刷新列表
+  await getImageList()
+}
+
+defineExpose({ getImageList }) // 暴露组件方法
+
+const emits = defineEmits(['onRegeneration'])
+
+/** 组件挂在的时候 */
+onMounted(async () => {
+  // 获取 image 列表
+  await getImageList()
+  // 自动刷新 image 列表
+  inProgressTimer.value = setInterval(async () => {
+    await refreshWatchImages()
+  }, 1000 * 3)
+})
+
+/** 组件取消挂在的时候 */
+onUnmounted(async () => {
+  if (inProgressTimer.value) {
+    clearInterval(inProgressTimer.value)
+  }
+})
+</script>
+<style lang="scss">
+.dr-task {
+  width: 100%;
+  height: 100%;
+}
+.task-card {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  position: relative;
+}
+
+.task-image-list {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-content: flex-start;
+  height: 100%;
+  overflow: auto;
+  padding: 20px 20px 140px;
+  box-sizing: border-box; /* 确保内边距不会增加高度 */
+
+  > div {
+    margin-right: 20px;
+    margin-bottom: 20px;
+  }
+  > div:last-of-type {
+    //margin-bottom: 100px;
+  }
+}
+
+.task-image-pagination {
+  position: absolute;
+  bottom: 60px;
+  height: 50px;
+  line-height: 90px;
+  width: 100%;
+  z-index: 999;
+  background-color: #ffffff;
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+}
+</style>

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

@@ -0,0 +1,320 @@
+<!-- dall3 -->
+<template>
+  <div class="prompt">
+    <el-text tag="b">画面描述</el-text>
+    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
+    <el-input
+      v-model="prompt"
+      maxlength="1024"
+      rows="5"
+      class="w-100% mt-15px"
+      input-style="border-radius: 7px;"
+      placeholder="例如:童话里的小屋应该是什么样子?"
+      show-word-limit
+      type="textarea"
+    />
+  </div>
+  <div class="hot-words">
+    <div>
+      <el-text tag="b">随机热词</el-text>
+    </div>
+    <el-space wrap class="word-list">
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in ImageHotWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
+      >
+        {{ hotWord }}
+      </el-button>
+    </el-space>
+  </div>
+  <div class="model">
+    <div>
+      <el-text tag="b">模型选择</el-text>
+    </div>
+    <el-space wrap class="model-list">
+      <div
+        :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
+        v-for="model in Dall3Models"
+        :key="model.key"
+      >
+        <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
+        <div class="model-font">{{ model.name }}</div>
+      </div>
+    </el-space>
+  </div>
+  <div class="image-style">
+    <div>
+      <el-text tag="b">风格选择</el-text>
+    </div>
+    <el-space wrap class="image-style-list">
+      <div
+        :class="style === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'"
+        v-for="imageStyle in Dall3StyleList"
+        :key="imageStyle.key"
+      >
+        <el-image :src="imageStyle.image" fit="contain" @click="handleStyleClick(imageStyle)" />
+        <div class="style-font">{{ imageStyle.name }}</div>
+      </div>
+    </el-space>
+  </div>
+  <div class="image-size">
+    <div>
+      <el-text tag="b">画面比例</el-text>
+    </div>
+    <el-space wrap class="size-list">
+      <div
+        class="size-item"
+        v-for="imageSize in Dall3SizeList"
+        :key="imageSize.key"
+        @click="handleSizeClick(imageSize)"
+      >
+        <div
+          :class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
+        >
+          <div :style="imageSize.style"></div>
+        </div>
+        <div class="size-font">{{ imageSize.name }}</div>
+      </div>
+    </el-space>
+  </div>
+  <div class="btns">
+    <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
+  </div>
+</template>
+<script setup lang="ts">
+import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
+import {
+  Dall3Models,
+  Dall3StyleList,
+  ImageHotWords,
+  Dall3SizeList,
+  ImageModelVO,
+  AiPlatformEnum
+} from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息弹窗
+
+// 定义属性
+const prompt = ref<string>('') // 提示词
+const drawIn = ref<boolean>(false) // 生成中
+const selectHotWord = ref<string>('') // 选中的热词
+const selectModel = ref<string>('dall-e-3') // 模型
+const selectSize = ref<string>('1024x1024') // 选中 size
+const style = ref<string>('vivid') // style 样式
+
+const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
+
+/** 选择热词 */
+const handleHotWordClick = async (hotWord: string) => {
+  // 情况一:取消选中
+  if (selectHotWord.value == hotWord) {
+    selectHotWord.value = ''
+    return
+  }
+
+  // 情况二:选中
+  selectHotWord.value = hotWord
+  prompt.value = hotWord
+}
+
+/** 选择 model 模型 */
+const handleModelClick = async (model: ImageModelVO) => {
+  selectModel.value = model.key
+}
+
+/** 选择 style 样式  */
+const handleStyleClick = async (imageStyle: ImageModelVO) => {
+  style.value = imageStyle.key
+}
+
+/** 选择 size 大小  */
+const handleSizeClick = async (imageSize: ImageSizeVO) => {
+  selectSize.value = imageSize.key
+}
+
+/**  图片生产  */
+const handleGenerateImage = async () => {
+  // 二次确认
+  await message.confirm(`确认生成内容?`)
+  try {
+    // 加载中
+    drawIn.value = true
+    // 回调
+    emits('onDrawStart', AiPlatformEnum.OPENAI)
+    const imageSize = Dall3SizeList.find((item) => item.key === selectSize.value) as ImageSizeVO
+    const form = {
+      platform: AiPlatformEnum.OPENAI,
+      prompt: prompt.value, // 提示词
+      model: selectModel.value, // 模型
+      width: imageSize.width, // size 不能为空
+      height: imageSize.height, // size 不能为空
+      options: {
+        style: style.value // 图像生成的风格
+      }
+    } as ImageDrawReqVO
+    // 发送请求
+    await ImageApi.drawImage(form)
+  } finally {
+    // 回调
+    emits('onDrawComplete', AiPlatformEnum.OPENAI)
+    // 加载结束
+    drawIn.value = false
+  }
+}
+
+/** 填充值 */
+const settingValues = async (detail: ImageVO) => {
+  prompt.value = detail.prompt
+  selectModel.value = detail.model
+  style.value = detail.options?.style
+  const imageSize = Dall3SizeList.find(
+    (item) => item.key === `${detail.width}x${detail.height}`
+  ) as ImageSizeVO
+  await handleSizeClick(imageSize)
+}
+
+/** 暴露组件方法 */
+defineExpose({ settingValues })
+</script>
+<style scoped lang="scss">
+// 提示词
+.prompt {
+}
+
+// 热词
+.hot-words {
+  display: flex;
+  flex-direction: column;
+  margin-top: 30px;
+
+  .word-list {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: start;
+    margin-top: 15px;
+
+    .btn {
+      margin: 0;
+    }
+  }
+}
+
+// 模型
+.model {
+  margin-top: 30px;
+
+  .model-list {
+    margin-top: 15px;
+
+    .modal-item {
+      width: 110px;
+      //outline: 1px solid blue;
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      border: 3px solid transparent;
+      cursor: pointer;
+
+      .model-font {
+        font-size: 14px;
+        color: #3e3e3e;
+        font-weight: bold;
+      }
+    }
+
+    .selectModel {
+      border: 3px solid #1293ff;
+      border-radius: 5px;
+    }
+  }
+}
+
+// 样式 style
+.image-style {
+  margin-top: 30px;
+
+  .image-style-list {
+    margin-top: 15px;
+
+    .image-style-item {
+      width: 110px;
+      //outline: 1px solid blue;
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      border: 3px solid transparent;
+      cursor: pointer;
+
+      .style-font {
+        font-size: 14px;
+        color: #3e3e3e;
+        font-weight: bold;
+      }
+    }
+
+    .selectImageStyle {
+      border: 3px solid #1293ff;
+      border-radius: 5px;
+    }
+  }
+}
+
+// 尺寸
+.image-size {
+  width: 100%;
+  margin-top: 30px;
+
+  .size-list {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    width: 100%;
+    margin-top: 20px;
+
+    .size-item {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      cursor: pointer;
+
+      .size-wrapper {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        border-radius: 7px;
+        padding: 4px;
+        width: 50px;
+        height: 50px;
+        background-color: #fff;
+        border: 1px solid #fff;
+      }
+
+      .size-font {
+        font-size: 14px;
+        color: #3e3e3e;
+        font-weight: bold;
+      }
+    }
+  }
+
+  .selectImageSize {
+    border: 1px solid #1293ff !important;
+  }
+}
+
+.btns {
+  display: flex;
+  justify-content: center;
+  margin-top: 50px;
+}
+</style>

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

@@ -0,0 +1,326 @@
+<!-- dall3 -->
+<template>
+  <div class="prompt">
+    <el-text tag="b">画面描述</el-text>
+    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开.</el-text>
+    <el-input
+      v-model="prompt"
+      maxlength="1024"
+      rows="5"
+      class="w-100% mt-15px"
+      input-style="border-radius: 7px;"
+      placeholder="例如:童话里的小屋应该是什么样子?"
+      show-word-limit
+      type="textarea"
+    />
+  </div>
+  <div class="hot-words">
+    <div>
+      <el-text tag="b">随机热词</el-text>
+    </div>
+    <el-space wrap class="word-list">
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in ImageHotWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
+      >
+        {{ hotWord }}
+      </el-button>
+    </el-space>
+  </div>
+  <div class="image-size">
+    <div>
+      <el-text tag="b">尺寸</el-text>
+    </div>
+    <el-space wrap class="size-list">
+      <div
+        class="size-item"
+        v-for="imageSize in MidjourneySizeList"
+        :key="imageSize.key"
+        @click="handleSizeClick(imageSize)"
+      >
+        <div
+          :class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
+        >
+          <div :style="imageSize.style"></div>
+        </div>
+        <div class="size-font">{{ imageSize.key }}</div>
+      </div>
+    </el-space>
+  </div>
+  <div class="model">
+    <div>
+      <el-text tag="b">模型</el-text>
+    </div>
+    <el-space wrap class="model-list">
+      <div
+        :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
+        v-for="model in MidjourneyModels"
+        :key="model.key"
+      >
+        <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
+        <div class="model-font">{{ model.name }}</div>
+      </div>
+    </el-space>
+  </div>
+  <div class="version">
+    <div>
+      <el-text tag="b">版本</el-text>
+    </div>
+    <el-space wrap class="version-list">
+      <el-select
+        v-model="selectVersion"
+        class="version-select !w-350px"
+        clearable
+        placeholder="请选择版本"
+      >
+        <el-option
+          v-for="item in versionList"
+          :key="item.value"
+          :label="item.label"
+          :value="item.value"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="model">
+    <div>
+      <el-text tag="b">参考图</el-text>
+    </div>
+    <el-space wrap class="model-list">
+      <UploadImg v-model="referImageUrl" height="120px" width="120px" />
+    </el-space>
+  </div>
+  <div class="btns">
+    <el-button type="primary" size="large" round @click="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
+  </div>
+</template>
+<script setup lang="ts">
+import { ImageApi, ImageMidjourneyImagineReqVO, ImageVO } from '@/api/ai/image'
+import {
+  AiPlatformEnum,
+  ImageHotWords,
+  ImageSizeVO,
+  ImageModelVO,
+  MidjourneyModels,
+  MidjourneySizeList,
+  MidjourneyVersions,
+  NijiVersionList
+} from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息弹窗
+
+// 定义属性
+const drawIn = ref<boolean>(false) // 生成中
+const selectHotWord = ref<string>('') // 选中的热词
+// 表单
+const prompt = ref<string>('') // 提示词
+const referImageUrl = ref<any>() // 参考图
+const selectModel = ref<string>('midjourney') // 选中的模型
+const selectSize = ref<string>('1:1') // 选中 size
+const selectVersion = ref<any>('6.0') // 选中的 version
+const versionList = ref<any>(MidjourneyVersions) // version 列表
+const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
+
+/** 选择热词 */
+const handleHotWordClick = async (hotWord: string) => {
+  // 情况一:取消选中
+  if (selectHotWord.value == hotWord) {
+    selectHotWord.value = ''
+    return
+  }
+
+  // 情况二:选中
+  selectHotWord.value = hotWord // 选中
+  prompt.value = hotWord // 设置提示次
+}
+
+/** 点击 size 尺寸 */
+const handleSizeClick = async (imageSize: ImageSizeVO) => {
+  selectSize.value = imageSize.key
+}
+
+/** 点击 model 模型 */
+const handleModelClick = async (model: ImageModelVO) => {
+  selectModel.value = model.key
+  if (model.key === 'niji') {
+    versionList.value = NijiVersionList // 默认选择 niji
+  } else {
+    versionList.value = MidjourneyVersions // 默认选择 midjourney
+  }
+  selectVersion.value = versionList.value[0].value
+}
+
+/** 图片生成 */
+const handleGenerateImage = async () => {
+  // 二次确认
+  await message.confirm(`确认生成内容?`)
+  try {
+    // 加载中
+    drawIn.value = true
+    // 回调
+    emits('onDrawStart', AiPlatformEnum.MIDJOURNEY)
+    // 发送请求
+    const imageSize = MidjourneySizeList.find(
+      (item) => selectSize.value === item.key
+    ) as ImageSizeVO
+    const req = {
+      prompt: prompt.value,
+      model: selectModel.value,
+      width: imageSize.width,
+      height: imageSize.height,
+      version: selectVersion.value,
+      referImageUrl: referImageUrl.value
+    } as ImageMidjourneyImagineReqVO
+    await ImageApi.midjourneyImagine(req)
+  } finally {
+    // 回调
+    emits('onDrawComplete', AiPlatformEnum.MIDJOURNEY)
+    // 加载结束
+    drawIn.value = false
+  }
+}
+
+/** 填充值 */
+const settingValues = async (detail: ImageVO) => {
+  // 提示词
+  prompt.value = detail.prompt
+  // image size
+  const imageSize = MidjourneySizeList.find(
+    (item) => item.key === `${detail.width}:${detail.height}`
+  ) as ImageSizeVO
+  selectSize.value = imageSize.key
+  // 选中模型
+  const model = MidjourneyModels.find((item) => item.key === detail.options?.model) as ImageModelVO
+  await handleModelClick(model)
+  // 版本
+  selectVersion.value = versionList.value.find(
+    (item) => item.value === detail.options?.version
+  ).value
+  // image
+  referImageUrl.value = detail.options.referImageUrl
+}
+
+/** 暴露组件方法 */
+defineExpose({ settingValues })
+</script>
+<style scoped lang="scss">
+// 提示词
+.prompt {
+}
+
+// 热词
+.hot-words {
+  display: flex;
+  flex-direction: column;
+  margin-top: 30px;
+
+  .word-list {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: start;
+    margin-top: 15px;
+
+    .btn {
+      margin: 0;
+    }
+  }
+}
+
+// version
+.version {
+  margin-top: 20px;
+
+  .version-list {
+    margin-top: 20px;
+    width: 100%;
+  }
+}
+
+// 模型
+.model {
+  margin-top: 30px;
+
+  .model-list {
+    margin-top: 15px;
+
+    .modal-item {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      width: 150px;
+      //outline: 1px solid blue;
+      overflow: hidden;
+      border: 3px solid transparent;
+      cursor: pointer;
+
+      .model-font {
+        font-size: 14px;
+        color: #3e3e3e;
+        font-weight: bold;
+      }
+    }
+
+    .selectModel {
+      border: 3px solid #1293ff;
+      border-radius: 5px;
+    }
+  }
+}
+
+// 尺寸
+.image-size {
+  width: 100%;
+  margin-top: 30px;
+
+  .size-list {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    width: 100%;
+    margin-top: 20px;
+
+    .size-item {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      cursor: pointer;
+
+      .size-wrapper {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        justify-content: center;
+        border-radius: 7px;
+        padding: 4px;
+        width: 50px;
+        height: 50px;
+        background-color: #fff;
+        border: 1px solid #fff;
+      }
+
+      .size-font {
+        font-size: 14px;
+        color: #3e3e3e;
+        font-weight: bold;
+      }
+    }
+  }
+
+  .selectImageSize {
+    border: 1px solid #1293ff !important;
+  }
+}
+
+.btns {
+  display: flex;
+  justify-content: center;
+  margin-top: 50px;
+}
+</style>

+ 212 - 0
src/views/ai/image/index/components/other/index.vue

@@ -0,0 +1,212 @@
+<!-- dall3 -->
+<template>
+  <div class="prompt">
+    <el-text tag="b">画面描述</el-text>
+    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
+    <el-input
+      v-model="prompt"
+      maxlength="1024"
+      rows="5"
+      class="w-100% mt-15px"
+      input-style="border-radius: 7px;"
+      placeholder="例如:童话里的小屋应该是什么样子?"
+      show-word-limit
+      type="textarea"
+    />
+  </div>
+  <div class="hot-words">
+    <div>
+      <el-text tag="b">随机热词</el-text>
+    </div>
+    <el-space wrap class="word-list">
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in ImageHotWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
+      >
+        {{ hotWord }}
+      </el-button>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">平台</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="otherPlatform" placeholder="Select" size="large" class="!w-350px" @change="handlerPlatformChange">
+        <el-option
+          v-for="item in OtherPlatformEnum"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">模型</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="model" placeholder="Select" size="large" class="!w-350px">
+        <el-option
+          v-for="item in models"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">图片尺寸</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input v-model="width" type="number" class="w-170px" placeholder="图片宽度" />
+      <el-input v-model="height" type="number" class="w-170px" placeholder="图片高度" />
+    </el-space>
+  </div>
+  <div class="btns">
+    <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
+  </div>
+</template>
+<script setup lang="ts">
+import {ImageApi, ImageDrawReqVO, ImageVO} from '@/api/ai/image'
+import {
+  AiPlatformEnum,
+  ImageHotWords,
+  ImageModelVO,
+  OtherPlatformEnum,
+  TongYiWanXiangModels
+} from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息弹窗
+
+// 定义属性
+const drawIn = ref<boolean>(false) // 生成中
+const selectHotWord = ref<string>('') // 选中的热词
+// 表单
+const prompt = ref<string>('') // 提示词
+const width = ref<number>(512) // 图片宽度
+const height = ref<number>(512) // 图片高度
+const otherPlatform = ref<string>(AiPlatformEnum.TONG_YI) // 平台
+const models = ref<ImageModelVO[]>(TongYiWanXiangModels) // 模型
+const model = ref<string>(models.value[0].key) // 模型
+
+
+const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
+
+/** 选择热词 */
+const handleHotWordClick = async (hotWord: string) => {
+  // 情况一:取消选中
+  if (selectHotWord.value == hotWord) {
+    selectHotWord.value = ''
+    return
+  }
+
+  // 情况二:选中
+  selectHotWord.value = hotWord // 选中
+  prompt.value = hotWord // 替换提示词
+}
+
+/** 图片生成 */
+const handleGenerateImage = async () => {
+  // 二次确认
+  await message.confirm(`确认生成内容?`)
+  try {
+    // 加载中
+    drawIn.value = true
+    // 回调
+    emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION)
+    // 发送请求
+    const form = {
+      platform: otherPlatform.value,
+      model: model.value, // 模型
+      prompt: prompt.value, // 提示词
+      width: width.value, // 图片宽度
+      height: height.value, // 图片高度
+      options: {
+      }
+    } as ImageDrawReqVO
+    await ImageApi.drawImage(form)
+  } finally {
+    // 回调
+    emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION)
+    // 加载结束
+    drawIn.value = false
+  }
+}
+
+/** 填充值 */
+const settingValues = async (detail: ImageVO) => {
+  prompt.value = detail.prompt
+  width.value = detail.width
+  height.value = detail.height
+
+}
+
+/** 平台切换 */
+const handlerPlatformChange = async (platform) => {
+  // 切换平台,切换模型、风格
+  if (AiPlatformEnum.YI_YAN === platform) {
+    models.value = TongYiWanXiangModels
+  } else {
+    models.value = []
+  }
+  // 切换平台,默认选择一个风格
+  if (models.value.length > 0) {
+    model.value = models.value[0].key
+  } else  {
+    model.value = ''
+  }
+}
+
+/** 暴露组件方法 */
+defineExpose({ settingValues })
+</script>
+<style scoped lang="scss">
+// 提示词
+.prompt {
+}
+
+// 热词
+.hot-words {
+  display: flex;
+  flex-direction: column;
+  margin-top: 30px;
+
+  .word-list {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: start;
+    margin-top: 15px;
+
+    .btn {
+      margin: 0;
+    }
+  }
+}
+
+// 模型
+.group-item {
+  margin-top: 30px;
+
+  .group-item-body {
+    margin-top: 15px;
+    width: 100%;
+  }
+}
+
+.btns {
+  display: flex;
+  justify-content: center;
+  margin-top: 50px;
+}
+</style>

+ 272 - 0
src/views/ai/image/index/components/stableDiffusion/index.vue

@@ -0,0 +1,272 @@
+<!-- dall3 -->
+<template>
+  <div class="prompt">
+    <el-text tag="b">画面描述</el-text>
+    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
+    <el-input
+      v-model="prompt"
+      maxlength="1024"
+      rows="5"
+      class="w-100% mt-15px"
+      input-style="border-radius: 7px;"
+      placeholder="例如:童话里的小屋应该是什么样子?"
+      show-word-limit
+      type="textarea"
+    />
+  </div>
+  <div class="hot-words">
+    <div>
+      <el-text tag="b">随机热词</el-text>
+    </div>
+    <el-space wrap class="word-list">
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in ImageHotEnglishWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
+      >
+        {{ hotWord }}
+      </el-button>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">采样方法</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="sampler" placeholder="Select" size="large" class="!w-350px">
+        <el-option
+          v-for="item in StableDiffusionSamplers"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">CLIP</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="clipGuidancePreset" placeholder="Select" size="large" class="!w-350px">
+        <el-option
+          v-for="item in StableDiffusionClipGuidancePresets"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">风格</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="stylePreset" placeholder="Select" size="large" class="!w-350px">
+        <el-option
+          v-for="item in StableDiffusionStylePresets"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">图片尺寸</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input v-model="width" class="w-170px" placeholder="图片宽度" />
+      <el-input v-model="height" class="w-170px" placeholder="图片高度" />
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">迭代步数</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input
+        v-model="steps"
+        type="number"
+        size="large"
+        class="!w-350px"
+        placeholder="Please input"
+      />
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">引导系数</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input
+        v-model="scale"
+        type="number"
+        size="large"
+        class="!w-350px"
+        placeholder="Please input"
+      />
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">随机因子</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input
+        v-model="seed"
+        type="number"
+        size="large"
+        class="!w-350px"
+        placeholder="Please input"
+      />
+    </el-space>
+  </div>
+  <div class="btns">
+    <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
+  </div>
+</template>
+<script setup lang="ts">
+import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
+import { hasChinese } from '@/views/ai/utils/utils'
+import {
+  AiPlatformEnum,
+  ImageHotEnglishWords,
+  StableDiffusionClipGuidancePresets,
+  StableDiffusionSamplers,
+  StableDiffusionStylePresets
+} from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息弹窗
+
+// 定义属性
+const drawIn = ref<boolean>(false) // 生成中
+const selectHotWord = ref<string>('') // 选中的热词
+// 表单
+const prompt = ref<string>('') // 提示词
+const width = ref<number>(512) // 图片宽度
+const height = ref<number>(512) // 图片高度
+const sampler = ref<string>('DDIM') // 采样方法
+const steps = ref<number>(20) // 迭代步数
+const seed = ref<number>(42) // 控制生成图像的随机性
+const scale = ref<number>(7.5) // 引导系数
+const clipGuidancePreset = ref<string>('NONE') // 文本提示相匹配的图像(clip_guidance_preset) 简称 CLIP
+const stylePreset = ref<string>('3d-model') // 风格
+
+const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
+
+/** 选择热词 */
+const handleHotWordClick = async (hotWord: string) => {
+  // 情况一:取消选中
+  if (selectHotWord.value == hotWord) {
+    selectHotWord.value = ''
+    return
+  }
+
+  // 情况二:选中
+  selectHotWord.value = hotWord // 选中
+  prompt.value = hotWord // 替换提示词
+}
+
+/** 图片生成 */
+const handleGenerateImage = async () => {
+  // 二次确认
+  if (hasChinese(prompt.value)) {
+    message.alert('暂不支持中文!')
+    return
+  }
+  await message.confirm(`确认生成内容?`)
+
+  try {
+    // 加载中
+    drawIn.value = true
+    // 回调
+    emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION)
+    // 发送请求
+    const form = {
+      platform: AiPlatformEnum.STABLE_DIFFUSION,
+      model: 'stable-diffusion-v1-6',
+      prompt: prompt.value, // 提示词
+      width: width.value, // 图片宽度
+      height: height.value, // 图片高度
+      options: {
+        seed: seed.value, // 随机种子
+        steps: steps.value, // 图片生成步数
+        scale: scale.value, // 引导系数
+        sampler: sampler.value, // 采样算法
+        clipGuidancePreset: clipGuidancePreset.value, // 文本提示相匹配的图像 CLIP
+        stylePreset: stylePreset.value // 风格
+      }
+    } as ImageDrawReqVO
+    await ImageApi.drawImage(form)
+  } finally {
+    // 回调
+    emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION)
+    // 加载结束
+    drawIn.value = false
+  }
+}
+
+/** 填充值 */
+const settingValues = async (detail: ImageVO) => {
+  prompt.value = detail.prompt
+  width.value = detail.width
+  height.value = detail.height
+  seed.value = detail.options?.seed
+  steps.value = detail.options?.steps
+  scale.value = detail.options?.scale
+  sampler.value = detail.options?.sampler
+  clipGuidancePreset.value = detail.options?.clipGuidancePreset
+  stylePreset.value = detail.options?.stylePreset
+}
+
+/** 暴露组件方法 */
+defineExpose({ settingValues })
+</script>
+<style scoped lang="scss">
+// 提示词
+.prompt {
+}
+
+// 热词
+.hot-words {
+  display: flex;
+  flex-direction: column;
+  margin-top: 30px;
+
+  .word-list {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    justify-content: start;
+    margin-top: 15px;
+
+    .btn {
+      margin: 0;
+    }
+  }
+}
+
+// 模型
+.group-item {
+  margin-top: 30px;
+
+  .group-item-body {
+    margin-top: 15px;
+    width: 100%;
+  }
+}
+
+.btns {
+  display: flex;
+  justify-content: center;
+  margin-top: 50px;
+}
+</style>

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

@@ -0,0 +1,140 @@
+<!-- image -->
+<template>
+  <div class="ai-image">
+    <div class="left">
+      <div class="segmented">
+        <el-segmented v-model="selectPlatform" :options="platformOptions" />
+      </div>
+      <div class="modal-switch-container">
+        <Dall3
+          v-if="selectPlatform === AiPlatformEnum.OPENAI"
+          ref="dall3Ref"
+          @on-draw-start="handleDrawStart"
+          @on-draw-complete="handleDrawComplete"
+        />
+        <Midjourney v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY" ref="midjourneyRef" />
+        <StableDiffusion
+          v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
+          ref="stableDiffusionRef"
+          @on-draw-complete="handleDrawComplete"
+        />
+        <Other
+          v-if="selectPlatform === 'other'"
+          ref="otherRef"
+          @on-draw-complete="handleDrawComplete"
+        />
+      </div>
+    </div>
+    <div class="main">
+      <ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ImageList from './components/ImageList.vue'
+import { AiPlatformEnum } from '@/views/ai/utils/constants'
+import { ImageVO } from '@/api/ai/image'
+import Dall3 from './components/dall3/index.vue'
+import Midjourney from './components/midjourney/index.vue'
+import StableDiffusion from './components/stableDiffusion/index.vue'
+import Other from './components/other/index.vue'
+
+const imageListRef = ref<any>() // image 列表 ref
+const dall3Ref = ref<any>() // dall3(openai) ref
+const midjourneyRef = ref<any>() // midjourney ref
+const stableDiffusionRef = ref<any>() // stable diffusion ref
+const otherRef = ref<any>() // stable diffusion ref
+
+// 定义属性
+const selectPlatform = ref(AiPlatformEnum.MIDJOURNEY)
+const platformOptions = [
+  {
+    label: 'DALL3 绘画',
+    value: AiPlatformEnum.OPENAI
+  },
+  {
+    label: 'MJ 绘画',
+    value: AiPlatformEnum.MIDJOURNEY
+  },
+  {
+    label: 'Stable Diffusion',
+    value: AiPlatformEnum.STABLE_DIFFUSION
+  },
+  {
+    label: '其他',
+    value: 'other'
+  }
+]
+
+/** 绘画 start  */
+const handleDrawStart = async (platform: string) => {}
+
+/** 绘画 complete */
+const handleDrawComplete = async (platform: string) => {
+  await imageListRef.value.getImageList()
+}
+
+/**  重新生成:将画图详情填充到对应平台  */
+const handleRegeneration = async (image: ImageVO) => {
+  // 切换平台
+  selectPlatform.value = image.platform
+  // 根据不同平台填充 image
+  await nextTick()
+  if (image.platform === AiPlatformEnum.MIDJOURNEY) {
+    midjourneyRef.value.settingValues(image)
+  } else if (image.platform === AiPlatformEnum.OPENAI) {
+    dall3Ref.value.settingValues(image)
+  } else if (image.platform === AiPlatformEnum.STABLE_DIFFUSION) {
+    stableDiffusionRef.value.settingValues(image)
+  }
+}
+</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>

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

@@ -0,0 +1,251 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="平台" prop="platform">
+        <el-select v-model="queryParams.status" placeholder="请选择平台" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="绘画状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择绘画状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_IMAGE_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="是否发布" prop="publicStatus">
+        <el-select
+          v-model="queryParams.publicStatus"
+          placeholder="请选择是否发布"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column label="图片" align="center" prop="picUrl" width="110px" fixed="left">
+        <template #default="{ row }">
+          <el-image
+            class="h-80px w-80px"
+            lazy
+            :src="row.picUrl"
+            :preview-src-list="[row.picUrl]"
+            preview-teleported
+            fit="cover"
+            v-if="row.picUrl?.length > 0"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="平台" align="center" prop="platform" width="120">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
+        </template>
+      </el-table-column>
+      <el-table-column label="模型" align="center" prop="model" width="180" />
+      <el-table-column label="绘画状态" align="center" prop="status" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_IMAGE_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="是否发布" align="center" prop="publicStatus">
+        <template #default="scope">
+          <el-switch
+            v-model="scope.row.publicStatus"
+            :active-value="true"
+            :inactive-value="false"
+            @change="handleUpdatePublicStatusChange(scope.row)"
+            :disabled="scope.row.status !== AiImageStatusEnum.SUCCESS"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="提示词" align="center" prop="prompt" width="180" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="宽度" align="center" prop="width" />
+      <el-table-column label="高度" align="center" prop="height" />
+      <el-table-column label="错误信息" align="center" prop="errorMessage" />
+      <el-table-column label="任务编号" align="center" prop="taskId" />
+      <el-table-column label="操作" align="center" width="100" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:image:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE, getStrDictOptions, getBoolDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { ImageApi, ImageVO } from '@/api/ai/image'
+import * as UserApi from '@/api/system/user'
+import { AiImageStatusEnum } from '@/views/ai/utils/constants'
+
+/** AI 绘画 列表 */
+defineOptions({ name: 'AiImageManager' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ImageVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined,
+  platform: undefined,
+  status: undefined,
+  publicStatus: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ImageApi.getImagePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ImageApi.deleteImage(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 修改是否发布 */
+const handleUpdatePublicStatusChange = async (row: ImageVO) => {
+  try {
+    // 修改状态的二次确认
+    const text = row.publicStatus ? '公开' : '私有'
+    await message.confirm('确认要"' + text + '"该图片吗?')
+    // 发起修改状态
+    await ImageApi.updateImage({
+      id: row.id,
+      publicStatus: row.publicStatus
+    })
+    await getList()
+  } catch {
+    row.publicStatus = !row.publicStatus
+  }
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 132 - 0
src/views/ai/model/apiKey/ApiKeyForm.vue

@@ -0,0 +1,132 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="所属平台" prop="platform">
+        <el-select v-model="formData.platform" placeholder="请输入平台" clearable>
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入名称" />
+      </el-form-item>
+      <el-form-item label="密钥" prop="apiKey">
+        <el-input v-model="formData.apiKey" placeholder="请输入密钥" />
+      </el-form-item>
+      <el-form-item label="自定义 API URL" prop="url">
+        <el-input v-model="formData.url" placeholder="请输入自定义 API URL" />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
+import { CommonStatusEnum } from '@/utils/constants'
+
+/** AI API 密钥 表单 */
+defineOptions({ name: 'ApiKeyForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  name: undefined,
+  apiKey: undefined,
+  platform: undefined,
+  url: undefined,
+  status: CommonStatusEnum.ENABLE
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名称不能为空', trigger: 'blur' }],
+  apiKey: [{ required: true, message: '密钥不能为空', trigger: 'blur' }],
+  platform: [{ required: true, message: '平台不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ApiKeyApi.getApiKey(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ApiKeyVO
+    if (formType.value === 'create') {
+      await ApiKeyApi.createApiKey(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ApiKeyApi.updateApiKey(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    name: undefined,
+    apiKey: undefined,
+    platform: undefined,
+    url: undefined,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 180 - 0
src/views/ai/model/apiKey/index.vue

@@ -0,0 +1,180 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="平台" prop="platform">
+        <el-select
+          v-model="queryParams.platform"
+          placeholder="请输入平台"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-240px">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['ai:api-key:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="所属平台" align="center" prop="platform">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
+        </template>
+      </el-table-column>
+      <el-table-column label="名称" align="center" prop="name" />
+      <el-table-column label="密钥" align="center" prop="apiKey" />
+      <el-table-column label="自定义 API URL" align="center" prop="url" />
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['ai:api-key:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:api-key:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ApiKeyForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, DICT_TYPE, getStrDictOptions } from '@/utils/dict'
+import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
+import ApiKeyForm from './ApiKeyForm.vue'
+
+/** AI API 密钥 列表 */
+defineOptions({ name: 'AiApiKey' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ApiKeyVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  platform: undefined,
+  status: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ApiKeyApi.getApiKeyPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ApiKeyApi.deleteApiKey(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 181 - 0
src/views/ai/model/chatModel/ChatModelForm.vue

@@ -0,0 +1,181 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="120px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="所属平台" prop="platform">
+        <el-select v-model="formData.platform" placeholder="请输入平台" clearable>
+          <el-option
+            v-for="dict in getStrDictOptions(DICT_TYPE.AI_PLATFORM)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="API 秘钥" prop="keyId">
+        <el-select v-model="formData.keyId" placeholder="请选择 API 秘钥" clearable>
+          <el-option
+            v-for="apiKey in apiKeyList"
+            :key="apiKey.id"
+            :label="apiKey.name"
+            :value="apiKey.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="模型名字" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入模型名字" />
+      </el-form-item>
+      <el-form-item label="模型标识" prop="model">
+        <el-input v-model="formData.model" placeholder="请输入模型标识" />
+      </el-form-item>
+      <el-form-item label="模型排序" prop="sort">
+        <el-input-number v-model="formData.sort" placeholder="请输入模型排序" class="!w-1/1" />
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="温度参数" prop="temperature">
+        <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-number
+          v-model="formData.maxTokens"
+          placeholder="请输入回复数 Token 数"
+          :min="0"
+          :max="4096"
+        />
+      </el-form-item>
+      <el-form-item label="上下文数量" prop="maxContexts">
+        <el-input-number
+          v-model="formData.maxContexts"
+          placeholder="请输入上下文数量"
+          :min="0"
+          :max="20"
+        />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
+import { CommonStatusEnum } from '@/utils/constants'
+import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
+
+/** API 聊天模型 表单 */
+defineOptions({ name: 'ChatModelForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  keyId: undefined,
+  name: undefined,
+  model: undefined,
+  platform: undefined,
+  sort: undefined,
+  status: CommonStatusEnum.ENABLE,
+  temperature: undefined,
+  maxTokens: undefined,
+  maxContexts: undefined
+})
+const formRules = reactive({
+  keyId: [{ required: true, message: 'API 秘钥不能为空', trigger: 'blur' }],
+  name: [{ required: true, message: '模型名字不能为空', trigger: 'blur' }],
+  model: [{ required: true, message: '模型标识不能为空', trigger: 'blur' }],
+  platform: [{ required: true, message: '所属平台不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '排序不能为空', trigger: 'blur' }],
+  status: [{ required: true, message: '状态不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ChatModelApi.getChatModel(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得下拉数据
+  apiKeyList.value = await ApiKeyApi.getApiKeySimpleList(CommonStatusEnum.ENABLE)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ChatModelVO
+    if (formType.value === 'create') {
+      await ChatModelApi.createChatModel(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ChatModelApi.updateChatModel(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    keyId: undefined,
+    name: undefined,
+    model: undefined,
+    platform: undefined,
+    sort: undefined,
+    status: CommonStatusEnum.ENABLE,
+    temperature: undefined,
+    maxTokens: undefined,
+    maxContexts: undefined
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 185 - 0
src/views/ai/model/chatModel/index.vue

@@ -0,0 +1,185 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="模型名字" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入模型名字"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="模型标识" prop="model">
+        <el-input
+          v-model="queryParams.model"
+          placeholder="请输入模型标识"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="模型平台" prop="platform">
+        <el-input
+          v-model="queryParams.platform"
+          placeholder="请输入模型平台"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['ai:chat-model:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="所属平台" align="center" prop="platform">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
+        </template>
+      </el-table-column>
+      <el-table-column label="模型名字" align="center" prop="name" />
+      <el-table-column label="模型标识" align="center" prop="model" />
+      <el-table-column label="API 秘钥" align="center" prop="keyId" min-width="140">
+        <template #default="scope">
+          <span>{{ apiKeyList.find((item) => item.id === scope.row.keyId)?.name }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="排序" align="center" prop="sort" />
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="温度参数" align="center" prop="temperature" />
+      <el-table-column label="回复数 Token 数" align="center" prop="maxTokens" min-width="140" />
+      <el-table-column label="上下文数量" align="center" prop="maxContexts" />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['ai:chat-model:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:chat-model:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ChatModelForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import ChatModelForm from './ChatModelForm.vue'
+import { DICT_TYPE } from '@/utils/dict'
+import { ApiKeyApi, ApiKeyVO } from '@/api/ai/model/apiKey'
+
+/** API 聊天模型 列表 */
+defineOptions({ name: 'AiChatModel' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ChatModelVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  model: undefined,
+  platform: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const apiKeyList = ref([] as ApiKeyVO[]) // API 密钥列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ChatModelApi.getChatModelPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ChatModelApi.deleteChatModel(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得下拉数据
+  apiKeyList.value = await ApiKeyApi.getApiKeySimpleList()
+})
+</script>

+ 183 - 0
src/views/ai/model/chatRole/ChatRoleForm.vue

@@ -0,0 +1,183 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="角色名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入角色名称" />
+      </el-form-item>
+      <el-form-item label="角色头像" prop="avatar">
+        <UploadImg v-model="formData.avatar" height="60px" width="60px" />
+      </el-form-item>
+      <el-form-item label="绑定模型" prop="modelId" v-if="!isUser">
+        <el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
+          <el-option
+            v-for="chatModel in chatModelList"
+            :key="chatModel.id"
+            :label="chatModel.name"
+            :value="chatModel.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="角色类别" prop="category" v-if="!isUser">
+        <el-input v-model="formData.category" placeholder="请输入角色类别" />
+      </el-form-item>
+      <el-form-item label="角色描述" prop="description">
+        <el-input type="textarea" v-model="formData.description" placeholder="请输入角色描述" />
+      </el-form-item>
+      <el-form-item label="角色设定" prop="systemMessage">
+        <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
+      </el-form-item>
+      <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
+        <el-radio-group v-model="formData.publicStatus">
+          <el-radio
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="角色排序" prop="sort" v-if="!isUser">
+        <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
+      </el-form-item>
+      <el-form-item label="开启状态" prop="status" v-if="!isUser">
+        <el-radio-group v-model="formData.status">
+          <el-radio
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.value"
+          >
+            {{ dict.label }}
+          </el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
+import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
+import { CommonStatusEnum } from '@/utils/constants'
+import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import {FormRules} from "element-plus";
+
+/** AI 聊天角色 表单 */
+defineOptions({ name: 'ChatRoleForm' })
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const formData = ref({
+  id: undefined,
+  modelId: undefined,
+  name: undefined,
+  avatar: undefined,
+  category: undefined,
+  sort: undefined,
+  description: undefined,
+  systemMessage: undefined,
+  publicStatus: true,
+  status: CommonStatusEnum.ENABLE
+})
+const formRef = ref() // 表单 Ref
+const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
+
+/** 是否【我】自己创建,私有角色 */
+const isUser = computed(() => {
+  return formType.value === 'my-create' || formType.value === 'my-update'
+})
+
+const formRules = reactive<FormRules>({
+  name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
+  avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
+  category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
+  description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
+  systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
+  publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
+})
+
+/** 打开弹窗 */
+// TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
+const open = async (type: string, id?: number, title?: string) => {
+  dialogVisible.value = true
+  dialogTitle.value = title || t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await ChatRoleApi.getChatRole(id)
+    } finally {
+      formLoading.value = false
+    }
+  }
+  // 获得下拉数据
+  chatModelList.value = await ChatModelApi.getChatModelSimpleList(CommonStatusEnum.ENABLE)
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  await formRef.value.validate()
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as ChatRoleVO
+    // tip: my-create、my-update 是 chat 角色仓库调用
+    // tip: create、else 是后台管理调用
+    if (formType.value === 'my-create') {
+      await ChatRoleApi.createMy(data)
+      message.success(t('common.createSuccess'))
+    } else if (formType.value === 'my-update') {
+      await ChatRoleApi.updateMy(data)
+      message.success(t('common.updateSuccess'))
+    } else if (formType.value === 'create') {
+      await ChatRoleApi.createChatRole(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await ChatRoleApi.updateChatRole(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    modelId: undefined,
+    name: undefined,
+    avatar: undefined,
+    category: undefined,
+    sort: undefined,
+    description: undefined,
+    systemMessage: undefined,
+    publicStatus: true,
+    status: CommonStatusEnum.ENABLE
+  }
+  formRef.value?.resetFields()
+}
+</script>

+ 187 - 0
src/views/ai/model/chatRole/index.vue

@@ -0,0 +1,187 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="角色名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          placeholder="请输入角色名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="角色类别" prop="category">
+        <el-input
+          v-model="queryParams.category"
+          placeholder="请输入角色类别"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="是否公开" prop="publicStatus">
+        <el-select
+          v-model="queryParams.publicStatus"
+          placeholder="请选择是否公开"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['ai:chat-role:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="角色名称" align="center" prop="name" />
+      <el-table-column label="绑定模型" align="center" prop="modelName" />
+      <el-table-column label="角色头像" align="center" prop="avatar">
+        <template #default="scope">
+          <el-image :src="scope?.row.avatar" class="w-32px h-32px" />
+        </template>
+      </el-table-column>
+      <el-table-column label="角色类别" align="center" prop="category" />
+      <el-table-column label="角色描述" align="center" prop="description" />
+      <el-table-column label="角色设定" align="center" prop="systemMessage" />
+      <el-table-column label="是否公开" align="center" prop="publicStatus">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.publicStatus" />
+        </template>
+      </el-table-column>
+      <el-table-column label="状态" align="center" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="角色排序" align="center" prop="sort" />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['ai:chat-role:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:chat-role:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <ChatRoleForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
+import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
+import ChatRoleForm from './ChatRoleForm.vue'
+
+/** AI 聊天角色 列表 */
+defineOptions({ name: 'AiChatRole' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<ChatRoleVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: undefined,
+  category: undefined,
+  publicStatus: true
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ChatRoleApi.getChatRolePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await ChatRoleApi.deleteChatRole(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

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

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

@@ -0,0 +1,292 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="音乐名称" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入音乐名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="音乐状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择音乐状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_MUSIC_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="生成模式" prop="generateMode">
+        <el-select
+          v-model="queryParams.generateMode"
+          placeholder="请选择生成模式"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_GENERATE_MODE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item label="是否发布" prop="publicStatus">
+        <el-select
+          v-model="queryParams.publicStatus"
+          placeholder="请选择是否发布"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column label="音乐名称" align="center" prop="title" width="180px" fixed="left" />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="音乐状态" align="center" prop="status" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_MUSIC_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="模型" align="center" prop="model" width="180" />
+      <el-table-column label="内容" align="center" width="180">
+        <template #default="{ row }">
+          <el-link
+            v-if="row.audioUrl?.length > 0"
+            type="primary"
+            :href="row.audioUrl"
+            target="_blank"
+          >
+            音乐
+          </el-link>
+          <el-link
+            v-if="row.videoUrl?.length > 0"
+            type="primary"
+            :href="row.videoUrl"
+            target="_blank"
+            class="!pl-5px"
+          >
+            视频
+          </el-link>
+          <el-link
+            v-if="row.imageUrl?.length > 0"
+            type="primary"
+            :href="row.imageUrl"
+            target="_blank"
+            class="!pl-5px"
+          >
+            封面
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="时长(秒)" align="center" prop="duration" width="100" />
+      <el-table-column label="提示词" align="center" prop="prompt" width="180" />
+      <el-table-column label="歌词" align="center" prop="lyric" width="180" />
+      <el-table-column label="描述" align="center" prop="gptDescriptionPrompt" width="180" />
+      <el-table-column label="生成模式" align="center" prop="generateMode" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_GENERATE_MODE" :value="scope.row.generateMode" />
+        </template>
+      </el-table-column>
+      <el-table-column label="风格标签" align="center" prop="tags" width="180">
+        <template #default="scope">
+          <el-tag v-for="tag in scope.row.tags" :key="tag" round class="ml-2px">
+            {{ tag }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="是否发布" align="center" prop="publicStatus">
+        <template #default="scope">
+          <el-switch
+            v-model="scope.row.publicStatus"
+            :active-value="true"
+            :inactive-value="false"
+            @change="handleUpdatePublicStatusChange(scope.row)"
+            :disabled="scope.row.status !== AiMusicStatusEnum.SUCCESS"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="任务编号" align="center" prop="taskId" width="180" />
+      <el-table-column label="错误信息" align="center" prop="errorMessage" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="操作" align="center" width="100" fixed="right">
+        <template #default="scope">
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:music:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import { MusicApi, MusicVO } from '@/api/ai/music'
+import * as UserApi from '@/api/system/user'
+import { AiMusicStatusEnum } from '@/views/ai/utils/constants'
+
+/** AI 音乐 列表 */
+defineOptions({ name: 'AiMusicManager' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<MusicVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined,
+  title: undefined,
+  status: undefined,
+  generateMode: undefined,
+  createTime: [],
+  publicStatus: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await MusicApi.getMusicPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await MusicApi.deleteMusic(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 修改是否发布 */
+const handleUpdatePublicStatusChange = async (row: MusicVO) => {
+  try {
+    // 修改状态的二次确认
+    const text = row.publicStatus ? '公开' : '私有'
+    await message.confirm('确认要"' + text + '"该音乐吗?')
+    // 发起修改状态
+    await MusicApi.updateMusic({
+      id: row.id,
+      publicStatus: row.publicStatus
+    })
+    await getList()
+  } catch {
+    row.publicStatus = !row.publicStatus
+  }
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

File diff suppressed because it is too large
+ 432 - 0
src/views/ai/utils/constants.ts


+ 13 - 0
src/views/ai/utils/utils.ts

@@ -0,0 +1,13 @@
+/**
+ * Created by 芋道源码
+ *
+ * AI 枚举类
+ *
+ * 问题:为什么不放在 src/utils/common-utils.ts 呢?
+ * 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/common-utils.ts
+ */
+
+/**  判断字符串是否包含中文  */
+export const hasChinese = (str: string) => {
+  return /[\u4e00-\u9fa5]/.test(str)
+}

+ 238 - 0
src/views/ai/write/manager/index.vue

@@ -0,0 +1,238 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="写作类型" prop="type">
+        <el-select
+          v-model="queryParams.type"
+          placeholder="请选择写作类型"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_WRITE_TYPE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="平台" prop="platform">
+        <el-select v-model="queryParams.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="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button
+          type="primary"
+          plain
+          @click="openForm('create')"
+          v-hasPermi="['ai:write:create']"
+        >
+          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        </el-button>
+        <el-button
+          type="success"
+          plain
+          @click="handleExport"
+          :loading="exportLoading"
+          v-hasPermi="['ai:write:export']"
+        >
+          <Icon icon="ep:download" class="mr-5px" /> 导出
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" width="120" fixed="left" />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="写作类型" align="center" prop="type">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_WRITE_TYPE" :value="scope.row.type" />
+        </template>
+      </el-table-column>
+      <el-table-column label="平台" align="center" prop="platform" width="120">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_PLATFORM" :value="scope.row.platform" />
+        </template>
+      </el-table-column>
+      <el-table-column label="模型" align="center" prop="model" width="180" />
+      <el-table-column label="生成内容提示" align="center" prop="prompt" width="180" />
+      <el-table-column label="生成的内容" align="center" prop="generatedContent" width="180" />
+      <el-table-column label="原文" align="center" prop="originalContent" width="180" />
+      <el-table-column label="长度" align="center" prop="length">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_WRITE_LENGTH" :value="scope.row.length" />
+        </template>
+      </el-table-column>
+      <el-table-column label="格式" align="center" prop="format">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_WRITE_FORMAT" :value="scope.row.format" />
+        </template>
+      </el-table-column>
+      <el-table-column label="语气" align="center" prop="tone">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_WRITE_TONE" :value="scope.row.tone" />
+        </template>
+      </el-table-column>
+      <el-table-column label="语言" align="center" prop="language">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_WRITE_LANGUAGE" :value="scope.row.language" />
+        </template>
+      </el-table-column>
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="错误信息" align="center" prop="errorMessage" />
+      <el-table-column label="操作" align="center">
+        <template #default="scope">
+          <el-button
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+            v-hasPermi="['ai:write:update']"
+          >
+            编辑
+          </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:write:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+</template>
+
+<script setup lang="ts">
+import { DICT_TYPE, getIntDictOptions, getStrDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+// TODO 芋艿:这里应该是 write
+import { WriteApi, WriteVO } from '@/api/ai/writer'
+import * as UserApi from '@/api/system/user'
+
+/** AI 写作列表 */
+defineOptions({ name: 'AiWriteManager' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<WriteVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined,
+  type: undefined,
+  platform: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await WriteApi.getWritePage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await WriteApi.deleteWrite(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 203 - 0
src/views/ai/writer/index/components/Left.vue

@@ -0,0 +1,203 @@
+<template>
+  <!-- 定义 tab 组件:撰写/回复等 -->
+  <DefineTab v-slot="{ active, text, itemClick }">
+    <span
+      class="inline-block w-1/2 rounded-full cursor-pointer text-center leading-[30px] relative z-1 text-[5C6370] hover:text-black"
+      :class="active ? 'text-black shadow-md' : 'hover:bg-[#DDDFE3]'"
+      @click="itemClick"
+    >
+      {{ text }}
+    </span>
+  </DefineTab>
+  <!-- 定义 label 组件:长度/格式/语气/语言等 -->
+  <DefineLabel v-slot="{ label, hint, hintClick }">
+    <h3 class="mt-5 mb-3 flex items-center justify-between text-[14px]">
+      <span>{{ label }}</span>
+      <span
+        @click="hintClick"
+        v-if="hint"
+        class="flex items-center text-[12px] text-[#846af7] cursor-pointer select-none"
+      >
+        <Icon icon="ep:question-filled" />
+        {{ hint }}
+      </span>
+    </h3>
+  </DefineLabel>
+
+  <div class="relative" v-bind="$attrs">
+    <!-- tab -->
+    <div
+      class="absolute left-1/2 top-2 -translate-x-1/2 w-[303px] rounded-full bg-[#DDDFE3] p-1 z-10"
+    >
+      <div
+        class="flex items-center relative after:content-[''] after:block after:bg-white after:h-[30px] after:w-1/2 after:absolute after:top-0 after:left-0 after:transition-transform after:rounded-full"
+        :class="selectedTab === AiWriteTypeEnum.REPLY && 'after:transform after:translate-x-[100%]'"
+      >
+        <ReuseTab
+          v-for="tab in tabs"
+          :key="tab.value"
+          :text="tab.text"
+          :active="tab.value === selectedTab"
+          :itemClick="() => switchTab(tab.value)"
+        />
+      </div>
+    </div>
+    <div
+      class="px-7 pb-2 pt-[46px] overflow-y-auto lg:block w-[380px] box-border bg-[#ECEDEF] h-full"
+    >
+      <div>
+        <template v-if="selectedTab === 1">
+          <ReuseLabel label="写作内容" hint="示例" :hint-click="() => example('write')" />
+          <el-input
+            type="textarea"
+            :rows="5"
+            :maxlength="500"
+            v-model="formData.prompt"
+            placeholder="请输入写作内容"
+            showWordLimit
+          />
+        </template>
+
+        <template v-else>
+          <ReuseLabel label="原文" hint="示例" :hint-click="() => example('reply')" />
+          <el-input
+            type="textarea"
+            :rows="5"
+            :maxlength="500"
+            v-model="formData.originalContent"
+            placeholder="请输入原文"
+            showWordLimit
+          />
+
+          <ReuseLabel label="回复内容" />
+          <el-input
+            type="textarea"
+            :rows="5"
+            :maxlength="500"
+            v-model="formData.prompt"
+            placeholder="请输入回复内容"
+            showWordLimit
+          />
+        </template>
+
+        <ReuseLabel label="长度" />
+        <Tag v-model="formData.length" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LENGTH)" />
+        <ReuseLabel label="格式" />
+        <Tag v-model="formData.format" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_FORMAT)" />
+        <ReuseLabel label="语气" />
+        <Tag v-model="formData.tone" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_TONE)" />
+        <ReuseLabel label="语言" />
+        <Tag v-model="formData.language" :tags="getIntDictOptions(DICT_TYPE.AI_WRITE_LANGUAGE)" />
+
+        <div class="flex items-center justify-center mt-3">
+          <el-button :disabled="isWriting" @click="reset">重置</el-button>
+          <el-button :loading="isWriting" @click="submit" color="#846af7">生成</el-button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { createReusableTemplate } from '@vueuse/core'
+import { ref } from 'vue'
+import Tag from './Tag.vue'
+import { WriteVO } from '@/api/ai/writer'
+import { omit } from 'lodash-es'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { AiWriteTypeEnum, WriteExample } from '@/views/ai/utils/constants'
+
+type TabType = WriteVO['type']
+
+const message = useMessage() // 消息弹窗
+
+defineProps<{
+  isWriting: boolean
+}>()
+
+const emits = defineEmits<{
+  (e: 'submit', params: Partial<WriteVO>)
+  (e: 'example', param: 'write' | 'reply')
+  (e: 'reset')
+}>()
+
+/** 点击示例的时候,将定义好的文章作为示例展示出来 **/
+const example = (type: 'write' | 'reply') => {
+  formData.value = {
+    ...initData,
+    ...omit(WriteExample[type], ['data'])
+  }
+  emits('example', type)
+}
+
+/** 重置,将表单值作为初选值 **/
+const reset = () => {
+  formData.value = { ...initData }
+  emits('reset')
+}
+
+const selectedTab = ref<TabType>(AiWriteTypeEnum.WRITING)
+const tabs: {
+  text: string
+  value: TabType
+}[] = [
+  { text: '撰写', value: AiWriteTypeEnum.WRITING },
+  { text: '回复', value: AiWriteTypeEnum.REPLY }
+]
+const [DefineTab, ReuseTab] = createReusableTemplate<{
+  active?: boolean
+  text: string
+  itemClick: () => void
+}>()
+
+/**
+ * 可以在 template 里边定义可复用的组件,DefineLabel,ReuseLabel 是采用的解构赋值,都是 Vue 组件
+ *
+ * 直接通过组件的形式使用,<DefineLabel v-slot="{ label, hint, hintClick }"> 中间是需要复用的组件代码 <DefineLabel />,通过 <ReuseLabel /> 来使用定义的组件
+ * DefineLabel 里边的 v-slot="{ label, hint, hintClick }"相当于是解构了组件的 prop,需要注意的是 boolean 类型,需要显式的赋值比如 <ReuseLabel :flag="true" />
+ * 事件也得以 prop 形式传入,不能是 @event的形式,比如下面的 hintClick 需要<ReuseLabel :hintClick="() => { doSomething }"/>
+ *
+ * @see https://vueuse.org/createReusableTemplate
+ */
+const [DefineLabel, ReuseLabel] = createReusableTemplate<{
+  label: string
+  class?: string
+  hint?: string
+  hintClick?: () => void
+}>()
+
+const initData: WriteVO = {
+  type: 1,
+  prompt: '',
+  originalContent: '',
+  tone: 1,
+  language: 1,
+  length: 1,
+  format: 1
+}
+const formData = ref<WriteVO>({ ...initData })
+
+/** 切换tab **/
+const switchTab = (value: TabType) => {
+  selectedTab.value = value
+  formData.value = { ...initData }
+}
+
+/** 提交写作 */
+const submit = () => {
+  if (selectedTab.value === 2 && !formData.value.originalContent) {
+    message.warning('请输入原文')
+    return
+  }
+  if (!formData.value.prompt) {
+    message.warning(`请输入${selectedTab.value === 1 ? '写作' : '回复'}内容`)
+    return
+  }
+  emits('submit', {
+    /** 撰写的时候没有 originalContent 字段**/
+    ...(selectedTab.value === 1 ? omit(formData.value, ['originalContent']) : formData.value),
+    /** 使用选中 tab 值覆盖当前的 type 类型 **/
+    type: selectedTab.value
+  })
+}
+</script>

+ 105 - 0
src/views/ai/writer/index/components/Right.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="h-full box-border flex flex-col px-7">
+    <h3 class="m-0 h-14 -mx-7 px-7 shrink-0 flex items-center justify-between bg-[#ecedef]">
+      <span>预览</span>
+      <!-- 展示在右上角 -->
+      <el-button color="#846af7" v-show="showCopy" @click="copyContent" size="small">
+        <template #icon>
+          <Icon icon="ph:copy-bold" />
+        </template>
+        复制
+      </el-button>
+    </h3>
+
+    <div ref="contentRef" class="hide-scroll-bar flex-grow box-border overflow-y-auto">
+      <div class="w-full min-h-full relative flex-grow bg-white box-border p-3 sm:p-7">
+        <!-- 终止生成内容的按钮 -->
+        <el-button
+          v-show="isWriting"
+          class="absolute bottom-2 sm:bottom-5 left-1/2 -translate-x-1/2 z-36"
+          @click="emits('stopStream')"
+          size="small"
+        >
+          <template #icon>
+            <Icon icon="material-symbols:stop" />
+          </template>
+          终止生成
+        </el-button>
+        <el-input
+          id="inputId"
+          type="textarea"
+          v-model="compContent"
+          autosize
+          :input-style="{ boxShadow: 'none' }"
+          resize="none"
+          placeholder="生成的内容……"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useClipboard } from '@vueuse/core'
+
+const message = useMessage() // 消息弹窗
+const { copied, copy } = useClipboard() // 粘贴板
+
+const props = defineProps({
+  content: {
+    // 生成的结果
+    type: String,
+    default: ''
+  },
+  isWriting: {
+    // 是否正在生成文章
+    type: Boolean,
+    default: false
+  }
+})
+
+const emits = defineEmits(['update:content', 'stopStream'])
+
+/** 通过计算属性,双向绑定,更改生成的内容,考虑到用户想要更改生成文章的情况 */
+const compContent = computed({
+  get() {
+    return props.content
+  },
+  set(val) {
+    emits('update:content', val)
+  }
+})
+
+/** 滚动 */
+const contentRef = ref<HTMLDivElement>()
+defineExpose({
+  scrollToBottom() {
+    contentRef.value?.scrollTo(0, contentRef.value?.scrollHeight)
+  }
+})
+
+/** 点击复制的时候复制内容 */
+const showCopy = computed(() => props.content && !props.isWriting) // 是否展示复制按钮,在生成内容完成的时候展示
+const copyContent = () => {
+  copy(props.content)
+}
+
+/** 复制成功的时候 copied.value 为 true */
+watch(copied, (val) => {
+  if (val) {
+    message.success('复制成功')
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.hide-scroll-bar {
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    height: 0;
+  }
+}
+</style>

+ 31 - 0
src/views/ai/writer/index/components/Tag.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="flex flex-wrap gap-[8px]">
+    <span
+      v-for="tag in props.tags"
+      :key="tag.value"
+      class="tag mb-2 border-[2px] border-solid border-[#DDDFE3] px-2 leading-6 text-[12px] bg-[#DDDFE3] rounded-[4px] cursor-pointer"
+      :class="modelValue === tag.value && '!border-[#846af7] text-[#846af7]'"
+      @click="emits('update:modelValue', tag.value)"
+    >
+      {{ tag.label }}
+    </span>
+  </div>
+</template>
+
+<script setup lang="ts">
+const props = withDefaults(
+  defineProps<{
+    tags: { label: string; value: string }[]
+    modelValue: string
+    [k: string]: any
+  }>(),
+  {
+    tags: () => []
+  }
+)
+
+const emits = defineEmits<{
+  (e: 'update:modelValue', value: string): void
+}>()
+</script>
+<style scoped></style>

+ 77 - 0
src/views/ai/writer/index/index.vue

@@ -0,0 +1,77 @@
+<template>
+  <!-- TODO @hhhero:整体没啥问题了。感觉整体框框的样子可以优化下,可以参考下绘图界面。例如说:1)写作的“预览”和绘图的“绘图任务”的 header;2)左右的边界,有个竖线之类的。 -->
+  <div class="h-[calc(100vh-var(--top-tool-height)-var(--app-footer-height)-40px)] -m-5 flex">
+    <Left
+      :is-writing="isWriting"
+      class="h-full"
+      @submit="submit"
+      @reset="reset"
+      @example="handleExampleClick"
+    />
+    <Right
+      :is-writing="isWriting"
+      @stop-stream="stopStream"
+      ref="rightRef"
+      class="flex-grow"
+      v-model:content="writeResult"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import Left from './components/Left.vue'
+import Right from './components/Right.vue'
+import * as WriteApi from '@/api/ai/writer'
+import { WriteExample } from '@/views/ai/utils/constants'
+
+const message = useMessage()
+
+const writeResult = ref('') // 写作结果
+const isWriting = ref(false) // 是否正在写作中
+const abortController = ref<AbortController>() // // 写作进行中 abort 控制器(控制 stream 写作)
+
+/** 停止 stream 生成 */
+const stopStream = () => {
+  abortController.value?.abort()
+  isWriting.value = false
+}
+
+/** 执行写作 */
+const rightRef = ref<InstanceType<typeof Right>>()
+const submit = (data) => {
+  abortController.value = new AbortController()
+  writeResult.value = ''
+  isWriting.value = true
+  WriteApi.writeStream({
+    data,
+    onMessage: async (res) => {
+      const { code, data, msg } = JSON.parse(res.data)
+      if (code !== 0) {
+        message.alert(`写作异常! ${msg}`)
+        stopStream()
+        return
+      }
+      writeResult.value = writeResult.value + data
+      // 滚动到底部
+      await nextTick()
+      rightRef.value?.scrollToBottom()
+    },
+    ctrl: abortController.value,
+    onClose: stopStream,
+    onError: (...err) => {
+      console.error('写作异常', ...err)
+      stopStream()
+    }
+  })
+}
+
+/** 点击示例触发 */
+const handleExampleClick = (type: keyof typeof WriteExampleDataJson) => {
+  writeResult.value = WriteExample[type].data
+}
+
+/** 点击重置的时候清空写作的结果**/
+const reset = () => {
+  writeResult.value = ''
+}
+</script>

+ 0 - 1
src/views/bpm/category/index.vue

@@ -126,7 +126,6 @@
 <script setup lang="ts">
 import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
-import download from '@/utils/download'
 import { CategoryApi, CategoryVO } from '@/api/bpm/category'
 import CategoryForm from './CategoryForm.vue'
 

+ 16 - 9
src/views/crm/statistics/performance/components/ContractCountPerformance.vue

@@ -1,5 +1,4 @@
 <!-- 员工业绩统计 -->
-<!-- TODO @scholar:参考 ReceivablePricePerformance 建议改 -->
 <template>
   <!-- Echarts图 -->
   <el-card shadow="never">
@@ -64,13 +63,13 @@ const echartsOption = reactive<EChartsOption>({
       data: []
     },
     {
-      name: '比增长率(%)',
+      name: '比增长率(%)',
       type: 'line',
       yAxisIndex: 1,
       data: []
     },
     {
-      name: '比增长率(%)',
+      name: '比增长率(%)',
       type: 'line',
       yAxisIndex: 1,
       data: []
@@ -173,7 +172,9 @@ const loadData = async () => {
       (s: StatisticsPerformanceRespVO) => s.lastMonthCount
     )
     echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
-      s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL'
+      s.lastMonthCount !== 0
+        ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
     )
   }
   if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
@@ -181,7 +182,9 @@ const loadData = async () => {
       (s: StatisticsPerformanceRespVO) => s.lastYearCount
     )
     echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
-      s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL'
+      s.lastYearCount !== 0
+        ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
     )
   }
 
@@ -197,8 +200,8 @@ const tableData = reactive([
   { title: '当月合同数量统计(个)' },
   { title: '上月合同数量统计(个)' },
   { title: '去年当月合同数量统计(个)' },
-  { title: '比增长率(%)' },
-  { title: '比增长率(%)' }
+  { title: '比增长率(%)' },
+  { title: '比增长率(%)' }
 ])
 
 // 定义 convertListData 方法,数据行列转置,展示每月数据
@@ -214,9 +217,13 @@ const convertListData = () => {
     tableData[1]['prop' + index] = item.lastMonthCount
     tableData[2]['prop' + index] = item.lastYearCount
     tableData[3]['prop' + index] =
-      item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL'
+      item.lastMonthCount !== 0
+        ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
     tableData[4]['prop' + index] =
-      item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL'
+      item.lastYearCount !== 0
+        ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
   })
 }
 

+ 16 - 9
src/views/crm/statistics/performance/components/ContractPricePerformance.vue

@@ -1,5 +1,4 @@
 <!-- 员工业绩统计 -->
-<!-- TODO @scholar:参考 ReceivablePricePerformance 建议改 -->
 <template>
   <!-- Echarts图 -->
   <el-card shadow="never">
@@ -64,13 +63,13 @@ const echartsOption = reactive<EChartsOption>({
       data: []
     },
     {
-      name: '比增长率(%)',
+      name: '比增长率(%)',
       type: 'line',
       yAxisIndex: 1,
       data: []
     },
     {
-      name: '比增长率(%)',
+      name: '比增长率(%)',
       type: 'line',
       yAxisIndex: 1,
       data: []
@@ -173,7 +172,9 @@ const loadData = async () => {
       (s: StatisticsPerformanceRespVO) => s.lastMonthCount
     )
     echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
-      s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL'
+      s.lastMonthCount !== 0
+        ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
     )
   }
   if (echartsOption.series && echartsOption.series[2] && echartsOption.series[2]['data']) {
@@ -181,7 +182,9 @@ const loadData = async () => {
       (s: StatisticsPerformanceRespVO) => s.lastYearCount
     )
     echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
-      s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL'
+      s.lastYearCount !== 0
+        ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
     )
   }
 
@@ -197,8 +200,8 @@ const tableData = reactive([
   { title: '当月合同金额统计(元)' },
   { title: '上月合同金额统计(元)' },
   { title: '去年当月合同金额统计(元)' },
-  { title: '比增长率(%)' },
-  { title: '比增长率(%)' }
+  { title: '比增长率(%)' },
+  { title: '比增长率(%)' }
 ])
 
 // 定义 init 方法
@@ -214,9 +217,13 @@ const convertListData = () => {
     tableData[1]['prop' + index] = item.lastMonthCount
     tableData[2]['prop' + index] = item.lastYearCount
     tableData[3]['prop' + index] =
-      item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL'
+      item.lastMonthCount !== 0
+        ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
     tableData[4]['prop' + index] =
-      item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL'
+      item.lastYearCount !== 0
+        ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
   })
 }
 

+ 17 - 13
src/views/crm/statistics/performance/components/ReceivablePricePerformance.vue

@@ -17,7 +17,6 @@
         :prop="item.prop"
         align="center"
       >
-        <!-- TODO @scholar:IDEA 爆红的处理 -->
         <template #default="scope">
           {{ scope.row[item.prop] }}
         </template>
@@ -64,13 +63,13 @@ const echartsOption = reactive<EChartsOption>({
       data: []
     },
     {
-      name: '比增长率(%)',
+      name: '比增长率(%)',
       type: 'line',
       yAxisIndex: 1,
       data: []
     },
     {
-      name: '比增长率(%)',
+      name: '比增长率(%)',
       type: 'line',
       yAxisIndex: 1,
       data: []
@@ -121,7 +120,6 @@ const echartsOption = reactive<EChartsOption>({
       type: 'value',
       name: '',
       axisTick: {
-        // TODO @scholar:IDEA 爆红的处理
         alignWithLabel: true,
         lineStyle: {
           width: 0
@@ -174,7 +172,9 @@ const loadData = async () => {
       (s: StatisticsPerformanceRespVO) => s.lastMonthCount
     )
     echartsOption.series[3]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
-      s.lastMonthCount !== 0 ? ((s.currentMonthCount / s.lastMonthCount) * 100).toFixed(2) : 'NULL'
+      s.lastMonthCount !== 0
+        ? (((s.currentMonthCount - s.lastMonthCount) / s.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
     )
   }
   if (echartsOption.series && echartsOption.series[2] && echartsOption.series[1]['data']) {
@@ -182,7 +182,9 @@ const loadData = async () => {
       (s: StatisticsPerformanceRespVO) => s.lastYearCount
     )
     echartsOption.series[4]['data'] = performanceList.map((s: StatisticsPerformanceRespVO) =>
-      s.lastYearCount !== 0 ? ((s.currentMonthCount / s.lastYearCount) * 100).toFixed(2) : 'NULL'
+      s.lastYearCount !== 0
+        ? (((s.currentMonthCount - s.lastYearCount) / s.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
     )
   }
 
@@ -193,14 +195,13 @@ const loadData = async () => {
 }
 
 // 初始化数据
-// TODO @scholar:加个 as any[],避免 idea 爆红
-const columnsData = reactive([] as any[])
+const columnsData = reactive([])
 const tableData = reactive([
   { title: '当月回款金额统计(元)' },
   { title: '上月回款金额统计(元)' },
   { title: '去年当月回款金额统计(元)' },
-  { title: '比增长率(%)' },
-  { title: '比增长率(%)' }
+  { title: '比增长率(%)' },
+  { title: '比增长率(%)' }
 ])
 
 // 定义 init 方法
@@ -215,11 +216,14 @@ const convertListData = () => {
     tableData[0]['prop' + index] = item.currentMonthCount
     tableData[1]['prop' + index] = item.lastMonthCount
     tableData[2]['prop' + index] = item.lastYearCount
-    // TODO @scholar:百分比,使用 erpCalculatePercentage 直接计算;如果是 0,则返回 0,统一就好哈;
     tableData[3]['prop' + index] =
-      item.lastMonthCount !== 0 ? (item.currentMonthCount / item.lastMonthCount).toFixed(2) : 'NULL'
+      item.lastMonthCount !== 0
+        ? (((item.currentMonthCount - item.lastMonthCount) / item.lastMonthCount) * 100).toFixed(2)
+        : 'NULL'
     tableData[4]['prop' + index] =
-      item.lastYearCount !== 0 ? (item.currentMonthCount / item.lastYearCount).toFixed(2) : 'NULL'
+      item.lastYearCount !== 0
+        ? (((item.currentMonthCount - item.lastYearCount) / item.lastYearCount) * 100).toFixed(2)
+        : 'NULL'
   })
 }
 

+ 9 - 20
src/views/crm/statistics/performance/index.vue

@@ -73,7 +73,7 @@
 import * as DeptApi from '@/api/system/dept'
 import * as UserApi from '@/api/system/user'
 import { useUserStore } from '@/store/modules/user'
-import { beginOfDay, formatDate } from '@/utils/formatTime'
+import { beginOfDay, endOfDay, formatDate } from '@/utils/formatTime'
 import { defaultProps, handleTree } from '@/utils/tree'
 import ContractCountPerformance from './components/ContractCountPerformance.vue'
 import ContractPricePerformance from './components/ContractPricePerformance.vue'
@@ -85,8 +85,8 @@ const queryParams = reactive({
   deptId: useUserStore().getUser.deptId,
   userId: undefined,
   times: [
-    // 默认显示当年的数据
-    formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7)))
+    formatDate(beginOfDay(new Date(new Date().getFullYear(), 0, 1))),
+    formatDate(endOfDay(new Date(new Date().getFullYear(), 11, 31)))
   ]
 })
 
@@ -100,31 +100,20 @@ const userListByDeptId = computed(() =>
     : []
 )
 
-// TODO @scholar:改成尾注释,保证 vue 内容短一点;变量名小写
 // 活跃标签
 const activeTab = ref('ContractCountPerformance')
-// 1.员工合同数量统计
-const ContractCountPerformanceRef = ref()
-// 2.员工合同金额统计
-const ContractPricePerformanceRef = ref()
-// 3.员工回款金额统计
-const ReceivablePricePerformanceRef = ref()
+const ContractCountPerformanceRef = ref() // 员工合同数量统计
+const ContractPricePerformanceRef = ref() // 员工合同金额统计
+const ReceivablePricePerformanceRef = ref() // 员工回款金额统计
 
 /** 搜索按钮操作 */
 const handleQuery = () => {
   // 从 queryParams.times[0] 中获取到了年份
   const selectYear = parseInt(queryParams.times[0])
+  queryParams.times[0] = formatDate(beginOfDay(new Date(selectYear, 0, 1)))
+  queryParams.times[1] = formatDate(endOfDay(new Date(selectYear, 11, 31)))
 
-  // 创建一个新的 Date 对象,设置为指定的年份的第一天
-  const fullDate = new Date(selectYear, 0, 1, 0, 0, 0)
-
-  // 将完整的日期时间格式化为需要的字符串形式,比如 2004-01-01 00:00:00
-  // TODO @scholar:看看,是不是可以使用 year 哈
-  queryParams.times[0] = `${fullDate.getFullYear()}-${String(fullDate.getMonth() + 1).padStart(
-    2,
-    '0'
-  )}-${String(fullDate.getDate()).padStart(2, '0')} ${String(fullDate.getHours()).padStart(2, '0')}:${String(fullDate.getMinutes()).padStart(2, '0')}:${String(fullDate.getSeconds()).padStart(2, '0')}`
-
+  // 执行查询
   switch (activeTab.value) {
     case 'ContractCountPerformance':
       ContractCountPerformanceRef.value?.loadData?.()

+ 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 {}
 }
 
 /** 删除按钮操作 */

+ 1 - 1
src/views/infra/job/logger/JobLogDetail.vue

@@ -26,7 +26,7 @@
         <dict-tag :type="DICT_TYPE.INFRA_JOB_LOG_STATUS" :value="detailData.status" />
       </el-descriptions-item>
       <el-descriptions-item label="执行结果">
-        {{ detailData.duration + ' result' }}
+        {{ detailData.result }}
       </el-descriptions-item>
     </el-descriptions>
   </Dialog>

+ 1 - 1
src/views/infra/webSocket/index.vue

@@ -29,8 +29,8 @@
         :autosize="{ minRows: 2, maxRows: 4 }"
         :disabled="!getIsOpen"
         clearable
-        type="textarea"
         placeholder="请输入你要发送的消息"
+        type="textarea"
       />
       <el-select v-model="sendUserId" class="mt-4" placeholder="请选择发送人">
         <el-option key="" label="所有人" value="" />

+ 24 - 12
src/views/mall/product/spu/components/SkuList.vue

@@ -292,6 +292,7 @@ import { createImageViewer } from '@/components/ImageViewer'
 import { RuleConfig } from '@/views/mall/product/spu/components/index'
 import { PropertyAndValues } from './index'
 import { ElTable } from 'element-plus'
+import { isEmpty } from '@/utils/is'
 
 defineOptions({ name: 'SkuList' })
 const message = useMessage() // 消息弹窗
@@ -340,11 +341,22 @@ const imagePreview = (imgUrl: string) => {
 
 /** 批量添加 */
 const batchAdd = () => {
+  validateProperty()
   formData.value!.skus!.forEach((item) => {
     copyValueToTarget(item, skuList.value[0])
   })
 }
-
+/** 校验商品属性属性值 */
+const validateProperty = () => {
+  // 校验商品属性属性值是否为空,有一个为空都不给过
+  const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!'
+  for (const item of props.propertyList) {
+    if (!item.values || isEmpty(item.values)) {
+      message.warning(warningInfo)
+      throw new Error(warningInfo)
+    }
+  }
+}
 /** 删除 sku */
 const deleteSku = (row) => {
   const index = formData.value!.skus!.findIndex(
@@ -358,6 +370,7 @@ const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表
  * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
  */
 const validateSku = () => {
+  validateProperty()
   let warningInfo = '请检查商品各行相关属性配置,'
   let validate = true // 默认通过
   for (const sku of formData.value!.skus!) {
@@ -421,7 +434,7 @@ watch(
 const generateTableData = (propertyList: any[]) => {
   // 构建数据结构
   const propertyValues = propertyList.map((item) =>
-    item.values.map((v) => ({
+    item.values.map((v: any) => ({
       propertyId: item.id,
       propertyName: item.name,
       valueId: v.id,
@@ -464,15 +477,14 @@ const generateTableData = (propertyList: any[]) => {
  */
 const validateData = (propertyList: any[]) => {
   const skuPropertyIds: number[] = []
-  formData.value!.skus!.forEach(
-    (sku) =>
-      sku.properties
-        ?.map((property) => property.propertyId)
-        ?.forEach((propertyId) => {
-          if (skuPropertyIds.indexOf(propertyId!) === -1) {
-            skuPropertyIds.push(propertyId!)
-          }
-        })
+  formData.value!.skus!.forEach((sku) =>
+    sku.properties
+      ?.map((property) => property.propertyId)
+      ?.forEach((propertyId) => {
+        if (skuPropertyIds.indexOf(propertyId!) === -1) {
+          skuPropertyIds.push(propertyId!)
+        }
+      })
   )
   const propertyIds = propertyList.map((item) => item.id)
   return skuPropertyIds.length === propertyIds.length
@@ -543,7 +555,7 @@ watch(
       return
     }
     // 添加新属性没有属性值也不做处理
-    if (propertyList.some((item) => item.values!.length === 0)) {
+    if (propertyList.some((item) => !item.values || isEmpty(item.values))) {
       return
     }
     // 生成 table 数据,即 sku 列表

+ 6 - 6
src/views/mall/product/spu/form/ProductAttributes.vue

@@ -3,7 +3,7 @@
   <el-col v-for="(item, index) in attributeList" :key="index">
     <div>
       <el-text class="mx-1">属性名:</el-text>
-      <el-tag class="mx-1" :closable="!isDetail" type="success" @close="handleCloseProperty(index)">
+      <el-tag :closable="!isDetail" class="mx-1" type="success" @close="handleCloseProperty(index)">
         {{ item.name }}
       </el-tag>
     </div>
@@ -12,8 +12,8 @@
       <el-tag
         v-for="(value, valueIndex) in item.values"
         :key="value.id"
-        class="mx-1"
         :closable="!isDetail"
+        class="mx-1"
         @close="handleCloseValue(index, valueIndex)"
       >
         {{ value.name }}
@@ -44,7 +44,6 @@
 <script lang="ts" setup>
 import { ElInput } from 'element-plus'
 import * as PropertyApi from '@/api/mall/product/property'
-import { PropertyVO } from '@/api/mall/product/property'
 import { PropertyAndValues } from '@/views/mall/product/spu/components'
 import { propTypes } from '@/utils/propTypes'
 
@@ -59,9 +58,9 @@ const inputVisible = computed(() => (index: number) => {
   if (attributeIndex.value === null) return false
   if (attributeIndex.value === index) return true
 })
-const inputRef = ref([]) //标签输入框Ref
+const inputRef = ref<any[]>([]) //标签输入框Ref
 /** 解决 ref 在 v-for 中的获取问题*/
-const setInputRef = (el) => {
+const setInputRef = (el: any) => {
   if (el === null || typeof el === 'undefined') return
   // 如果不存在 id 相同的元素才添加
   if (!inputRef.value.some((item) => item.input?.attributes.id === el.input?.attributes.id)) {
@@ -81,7 +80,7 @@ watch(
   () => props.propertyList,
   (data) => {
     if (!data) return
-    attributeList.value = data
+    attributeList.value = data as any
   },
   {
     deep: true,
@@ -97,6 +96,7 @@ const handleCloseValue = (index: number, valueIndex: number) => {
 /** 删除属性*/
 const handleCloseProperty = (index: number) => {
   attributeList.value?.splice(index, 1)
+  emit('success', attributeList.value)
 }
 
 /** 显示输入框并获取焦点 */

+ 7 - 7
src/views/mall/product/spu/form/SkuForm.vue

@@ -1,18 +1,18 @@
 <!-- 商品发布 - 库存价格 -->
 <template>
-  <el-form ref="formRef" :model="formData" :rules="rules" label-width="120px" :disabled="isDetail">
+  <el-form ref="formRef" :disabled="isDetail" :model="formData" :rules="rules" label-width="120px">
     <el-form-item label="分销类型" props="subCommissionType">
       <el-radio-group
         v-model="formData.subCommissionType"
-        @change="changeSubCommissionType"
         class="w-80"
+        @change="changeSubCommissionType"
       >
         <el-radio :label="false">默认设置</el-radio>
         <el-radio :label="true" class="radio">单独设置</el-radio>
       </el-radio-group>
     </el-form-item>
     <el-form-item label="商品规格" props="specType">
-      <el-radio-group v-model="formData.specType" @change="onChangeSpec" class="w-80">
+      <el-radio-group v-model="formData.specType" class="w-80" @change="onChangeSpec">
         <el-radio :label="false" class="radio">单规格</el-radio>
         <el-radio :label="true">多规格</el-radio>
       </el-radio-group>
@@ -29,22 +29,22 @@
     <el-form-item v-if="formData.specType" label="商品属性">
       <el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button>
       <ProductAttributes
+        :is-detail="isDetail"
         :property-list="propertyList"
         @success="generateSkus"
-        :is-detail="isDetail"
       />
     </el-form-item>
     <template v-if="formData.specType && propertyList.length > 0">
-      <el-form-item label="批量设置" v-if="!isDetail">
+      <el-form-item v-if="!isDetail" label="批量设置">
         <SkuList :is-batch="true" :prop-form-data="formData" :property-list="propertyList" />
       </el-form-item>
       <el-form-item label="规格列表">
         <SkuList
           ref="skuListRef"
+          :is-detail="isDetail"
           :prop-form-data="formData"
           :property-list="propertyList"
           :rule-config="ruleConfig"
-          :is-detail="isDetail"
         />
       </el-form-item>
     </template>
@@ -181,7 +181,7 @@ const onChangeSpec = () => {
 }
 
 /** 调用 SkuList generateTableData 方法*/
-const generateSkus = (propertyList) => {
+const generateSkus = (propertyList: any[]) => {
   skuListRef.value.generateTableData(propertyList)
 }
 </script>

+ 226 - 0
src/views/mall/promotion/kefu/components/KeFuConversationList.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="kefu">
+    <div
+      v-for="item in conversationList"
+      :key="item.id"
+      :class="{ active: item.id === activeConversationId, pinned: item.adminPinned }"
+      class="kefu-conversation flex items-center"
+      @click="openRightMessage(item)"
+      @contextmenu.prevent="rightClick($event as PointerEvent, item)"
+    >
+      <div class="flex justify-center items-center w-100%">
+        <div class="flex justify-center items-center w-50px h-50px">
+          <!-- 头像 + 未读 -->
+          <el-badge
+            :hidden="item.adminUnreadMessageCount === 0"
+            :max="99"
+            :value="item.adminUnreadMessageCount"
+          >
+            <el-avatar :src="item.userAvatar" alt="avatar" />
+          </el-badge>
+        </div>
+        <div class="ml-10px w-100%">
+          <div class="flex justify-between items-center w-100%">
+            <span>{{ item.userNickname }}</span>
+            <span class="color-[#989EA6]">
+              {{ formatDate(item.lastMessageTime) }}
+            </span>
+          </div>
+          <!-- 最后聊天内容 -->
+          <div
+            v-dompurify-html="
+              getConversationDisplayText(item.lastMessageContentType, item.lastMessageContent)
+            "
+            class="last-message flex items-center color-[#989EA6]"
+          ></div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 右键,进行操作(类似微信) -->
+    <ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
+      <li
+        v-show="!rightClickConversation.adminPinned"
+        class="flex items-center"
+        @click.stop="updateConversationPinned(true)"
+      >
+        <Icon class="mr-5px" icon="ep:top" />
+        置顶会话
+      </li>
+      <li
+        v-show="rightClickConversation.adminPinned"
+        class="flex items-center"
+        @click.stop="updateConversationPinned(false)"
+      >
+        <Icon class="mr-5px" icon="ep:bottom" />
+        取消置顶
+      </li>
+      <li class="flex items-center" @click.stop="deleteConversation">
+        <Icon class="mr-5px" color="red" icon="ep:delete" />
+        删除会话
+      </li>
+      <li class="flex items-center" @click.stop="closeRightMenu">
+        <Icon class="mr-5px" color="red" icon="ep:close" />
+        取消
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import { useEmoji } from './tools/emoji'
+import { formatDate } from '@/utils/formatTime'
+import { KeFuMessageContentTypeEnum } from './tools/constants'
+import { useAppStore } from '@/store/modules/app'
+
+defineOptions({ name: 'KeFuConversationList' })
+
+const message = useMessage() // 消息弹窗
+const appStore = useAppStore()
+const { replaceEmoji } = useEmoji()
+const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表
+const activeConversationId = ref(-1) // 选中的会话
+const collapse = computed(() => appStore.getCollapse) // 折叠菜单
+
+/** 加载会话列表 */
+const getConversationList = async () => {
+  const list = await KeFuConversationApi.getConversationList()
+  list.sort((a: KeFuConversationRespVO, _) => (a.adminPinned ? -1 : 1))
+  conversationList.value = list
+}
+defineExpose({ getConversationList })
+
+/** 打开右侧的消息列表 */
+const emits = defineEmits<{
+  (e: 'change', v: KeFuConversationRespVO): void
+}>()
+const openRightMessage = (item: KeFuConversationRespVO) => {
+  activeConversationId.value = item.id
+  emits('change', item)
+}
+
+/** 获得消息类型 */
+const getConversationDisplayText = computed(
+  () => (lastMessageContentType: number, lastMessageContent: string) => {
+    switch (lastMessageContentType) {
+      case KeFuMessageContentTypeEnum.SYSTEM:
+        return '[系统消息]'
+      case KeFuMessageContentTypeEnum.VIDEO:
+        return '[视频消息]'
+      case KeFuMessageContentTypeEnum.IMAGE:
+        return '[图片消息]'
+      case KeFuMessageContentTypeEnum.PRODUCT:
+        return '[商品消息]'
+      case KeFuMessageContentTypeEnum.ORDER:
+        return '[订单消息]'
+      case KeFuMessageContentTypeEnum.VOICE:
+        return '[语音消息]'
+      case KeFuMessageContentTypeEnum.TEXT:
+        return replaceEmoji(lastMessageContent)
+      default:
+        return ''
+    }
+  }
+)
+
+//======================= 右键菜单 =======================
+const showRightMenu = ref(false) // 显示右键菜单
+const rightMenuStyle = ref<any>({}) // 右键菜单 Style
+const rightClickConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 右键选中的会话对象
+
+/** 打开右键菜单 */
+const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
+  rightClickConversation.value = item
+  // 显示右键菜单
+  showRightMenu.value = true
+  rightMenuStyle.value = {
+    top: mouseEvent.clientY - 110 + 'px',
+    left: collapse.value ? mouseEvent.clientX - 80 + 'px' : mouseEvent.clientX - 210 + 'px'
+  }
+}
+/** 关闭右键菜单 */
+const closeRightMenu = () => {
+  showRightMenu.value = false
+}
+
+/** 置顶会话 */
+const updateConversationPinned = async (adminPinned: boolean) => {
+  // 1. 会话置顶/取消置顶
+  await KeFuConversationApi.updateConversationPinned({
+    id: rightClickConversation.value.id,
+    adminPinned
+  })
+  message.notifySuccess(adminPinned ? '置顶成功' : '取消置顶成功')
+  // 2. 关闭右键菜单,更新会话列表
+  closeRightMenu()
+  await getConversationList()
+}
+
+/** 删除会话 */
+const deleteConversation = async () => {
+  // 1. 删除会话
+  await message.confirm('您确定要删除该会话吗?')
+  await KeFuConversationApi.deleteConversation(rightClickConversation.value.id)
+  // 2. 关闭右键菜单,更新会话列表
+  closeRightMenu()
+  await getConversationList()
+}
+
+/** 监听右键菜单的显示状态,添加点击事件监听器 */
+watch(showRightMenu, (val) => {
+  if (val) {
+    document.body.addEventListener('click', closeRightMenu)
+  } else {
+    document.body.removeEventListener('click', closeRightMenu)
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  &-conversation {
+    height: 60px;
+    padding: 10px;
+    background-color: #fff;
+    transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
+
+    .last-message {
+      width: 200px;
+      overflow: hidden; // 隐藏超出的文本
+      white-space: nowrap; // 禁止换行
+      text-overflow: ellipsis; // 添加省略号
+    }
+  }
+
+  .active {
+    border-left: 5px #3271ff solid;
+    background-color: #eff0f1;
+  }
+
+  .pinned {
+    background-color: #eff0f1;
+  }
+
+  .right-menu-ul {
+    position: absolute;
+    background-color: #fff;
+    padding: 10px;
+    margin: 0;
+    list-style-type: none; /* 移除默认的项目符号 */
+    border-radius: 12px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+    width: 130px;
+
+    li {
+      padding: 8px 16px;
+      cursor: pointer;
+      border-radius: 12px;
+      transition: background-color 0.3s; /* 平滑过渡 */
+      &:hover {
+        background-color: #e0e0e0; /* 悬停时的背景颜色 */
+      }
+    }
+  }
+}
+</style>

+ 399 - 0
src/views/mall/promotion/kefu/components/KeFuMessageList.vue

@@ -0,0 +1,399 @@
+<template>
+  <el-container v-if="showKeFuMessageList" class="kefu">
+    <el-header>
+      <div class="kefu-title">{{ conversation.userNickname }}</div>
+    </el-header>
+    <el-main class="kefu-content overflow-visible">
+      <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)" @scroll="handleScroll">
+        <div v-if="refreshContent" ref="innerRef" class="w-[100%] pb-3px">
+          <!-- 消息列表 -->
+          <div v-for="(item, index) in getMessageList0" :key="item.id" class="w-[100%]">
+            <div class="flex justify-center items-center mb-20px">
+              <!-- 日期 -->
+              <div
+                v-if="
+                  item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index)
+                "
+                class="date-message"
+              >
+                {{ formatDate(item.createTime) }}
+              </div>
+              <!-- 系统消息 -->
+              <div
+                v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
+                class="system-message"
+              >
+                {{ item.content }}
+              </div>
+            </div>
+            <div
+              :class="[
+                item.senderType === UserTypeEnum.MEMBER
+                  ? `ss-row-left`
+                  : item.senderType === UserTypeEnum.ADMIN
+                    ? `ss-row-right`
+                    : ''
+              ]"
+              class="flex mb-20px w-[100%]"
+            >
+              <el-avatar
+                v-if="item.senderType === UserTypeEnum.MEMBER"
+                :src="conversation.userAvatar"
+                alt="avatar"
+              />
+              <div
+                :class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }"
+                class="p-10px"
+              >
+                <!-- 文本消息 -->
+                <TextMessageItem :message="item" />
+                <!-- 图片消息 -->
+                <ImageMessageItem :message="item" />
+                <!-- 商品消息 -->
+                <ProductMessageItem :message="item" />
+                <!-- 订单消息 -->
+                <OrderMessageItem :message="item" />
+              </div>
+              <el-avatar
+                v-if="item.senderType === UserTypeEnum.ADMIN"
+                :src="item.senderAvatar"
+                alt="avatar"
+              />
+            </div>
+          </div>
+        </div>
+      </el-scrollbar>
+      <div
+        v-show="showNewMessageTip"
+        class="newMessageTip flex items-center cursor-pointer"
+        @click="handleToNewMessage"
+      >
+        <span>有新消息</span>
+        <Icon class="ml-5px" icon="ep:bottom" />
+      </div>
+    </el-main>
+    <el-footer height="230px">
+      <div class="h-[100%]">
+        <div class="chat-tools flex items-center">
+          <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
+          <PictureSelectUpload
+            class="ml-15px mt-3px cursor-pointer"
+            @send-picture="handleSendPicture"
+          />
+        </div>
+        <el-input v-model="message" :rows="6" style="border-style: none" type="textarea" />
+        <div class="h-45px flex justify-end">
+          <el-button class="mt-10px" type="primary" @click="handleSendMessage">发送</el-button>
+        </div>
+      </div>
+    </el-footer>
+  </el-container>
+  <el-empty v-else description="请选择左侧的一个会话后开始" />
+</template>
+
+<script lang="ts" setup>
+import { ElScrollbar as ElScrollbarType } from 'element-plus'
+import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import EmojiSelectPopover from './tools/EmojiSelectPopover.vue'
+import PictureSelectUpload from './tools/PictureSelectUpload.vue'
+import TextMessageItem from './message/TextMessageItem.vue'
+import ImageMessageItem from './message/ImageMessageItem.vue'
+import ProductMessageItem from './message/ProductMessageItem.vue'
+import OrderMessageItem from './message/OrderMessageItem.vue'
+import { Emoji } from './tools/emoji'
+import { KeFuMessageContentTypeEnum } from './tools/constants'
+import { isEmpty } from '@/utils/is'
+import { UserTypeEnum } from '@/utils/constants'
+import { formatDate } from '@/utils/formatTime'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+
+dayjs.extend(relativeTime)
+
+defineOptions({ name: 'KeFuMessageList' })
+
+const message = ref('') // 消息弹窗
+
+const messageTool = useMessage()
+const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
+const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
+const showNewMessageTip = ref(false) // 显示有新消息提示
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  conversationId: 0
+})
+const total = ref(0) // 消息总条数
+const refreshContent = ref(false) // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
+/** 获得消息列表 */
+const getMessageList = async (val: KeFuConversationRespVO, conversationChange: boolean) => {
+  // 会话切换,重置相关参数
+  if (conversationChange) {
+    queryParams.pageNo = 1
+    messageList.value = []
+    total.value = 0
+    loadHistory.value = false
+    refreshContent.value = false
+  }
+  conversation.value = val
+  queryParams.conversationId = val.id
+  const res = await KeFuMessageApi.getKeFuMessagePage(queryParams)
+  total.value = res.total
+  // 情况一:加载最新消息
+  if (queryParams.pageNo === 1) {
+    messageList.value = res.list
+  } else {
+    // 情况二:加载历史消息
+    for (const item of res.list) {
+      if (messageList.value.some((val) => val.id === item.id)) {
+        continue
+      }
+      messageList.value.push(item)
+    }
+  }
+  refreshContent.value = true
+  await scrollToBottom()
+}
+
+/** 按照时间倒序,获取消息列表 */
+const getMessageList0 = computed(() => {
+  messageList.value.sort((a: any, b: any) => a.createTime - b.createTime)
+  return messageList.value
+})
+
+/** 刷新消息列表 */
+const refreshMessageList = async () => {
+  if (!conversation.value) {
+    return
+  }
+
+  queryParams.pageNo = 1
+  await getMessageList(conversation.value, false)
+  if (loadHistory.value) {
+    // 右下角显示有新消息提示
+    showNewMessageTip.value = true
+  }
+}
+
+defineExpose({ getMessageList, refreshMessageList })
+const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) // 是否显示聊天区域
+const skipGetMessageList = computed(() => {
+  // 已加载到最后一页的话则不触发新的消息获取
+  return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
+}) // 跳过消息获取
+
+/** 处理表情选择 */
+const handleEmojiSelect = (item: Emoji) => {
+  message.value += item.name
+}
+
+/** 处理图片发送 */
+const handleSendPicture = async (picUrl: string) => {
+  // 组织发送消息
+  const msg = {
+    conversationId: conversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.IMAGE,
+    content: picUrl
+  }
+  await sendMessage(msg)
+}
+
+/** 发送文本消息 */
+const handleSendMessage = async () => {
+  // 1. 校验消息是否为空
+  if (isEmpty(unref(message.value))) {
+    messageTool.notifyWarning('请输入消息后再发送哦!')
+    return
+  }
+  // 2. 组织发送消息
+  const msg = {
+    conversationId: conversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.TEXT,
+    content: message.value
+  }
+  await sendMessage(msg)
+}
+
+/** 真正发送消息 【共用】*/
+const sendMessage = async (msg: any) => {
+  // 发送消息
+  await KeFuMessageApi.sendKeFuMessage(msg)
+  message.value = ''
+  // 加载消息列表
+  await getMessageList(conversation.value, false)
+  // 滚动到最新消息处
+  await scrollToBottom()
+}
+
+/** 滚动到底部 */
+const innerRef = ref<HTMLDivElement>()
+const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
+const scrollToBottom = async () => {
+  // 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
+  if (loadHistory.value) {
+    return
+  }
+  // 2.1 滚动到最新消息,关闭新消息提示
+  await nextTick()
+  scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
+  showNewMessageTip.value = false
+  // 2.2 消息已读
+  await KeFuMessageApi.updateKeFuMessageReadStatus(conversation.value.id)
+}
+
+/** 查看新消息 */
+const handleToNewMessage = async () => {
+  loadHistory.value = false
+  await scrollToBottom()
+}
+
+/** 加载历史消息 */
+const loadHistory = ref(false) // 加载历史消息
+const handleScroll = async ({ scrollTop }) => {
+  if (skipGetMessageList.value) {
+    return
+  }
+  // 触顶自动加载下一页数据
+  if (scrollTop === 0) {
+    await handleOldMessage()
+  }
+}
+const handleOldMessage = async () => {
+  // 记录已有页面高度
+  const oldPageHeight = innerRef.value?.clientHeight
+  if (!oldPageHeight) {
+    return
+  }
+  loadHistory.value = true
+  // 加载消息列表
+  queryParams.pageNo += 1
+  await getMessageList(conversation.value, false)
+  // 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
+  scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
+}
+
+/**
+ * 是否显示时间
+ *
+ * @param {*} item - 数据
+ * @param {*} index - 索引
+ */
+const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
+  if (unref(messageList.value)[index + 1]) {
+    let dateString = dayjs(unref(messageList.value)[index + 1].createTime).fromNow()
+    return dateString !== dayjs(unref(item).createTime).fromNow()
+  }
+  return false
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  &-title {
+    border-bottom: #e4e0e0 solid 1px;
+    height: 60px;
+    line-height: 60px;
+  }
+
+  &-content {
+    position: relative;
+
+    .newMessageTip {
+      position: absolute;
+      bottom: 35px;
+      right: 35px;
+      background-color: #fff;
+      padding: 10px;
+      border-radius: 30px;
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+    }
+
+    .ss-row-left {
+      justify-content: flex-start;
+
+      .kefu-message {
+        margin-left: 20px;
+        position: relative;
+
+        &::before {
+          content: '';
+          width: 10px;
+          height: 10px;
+          left: -19px;
+          top: calc(50% - 10px);
+          position: absolute;
+          border-left: 5px solid transparent;
+          border-bottom: 5px solid transparent;
+          border-top: 5px solid transparent;
+          border-right: 5px solid #ffffff;
+        }
+      }
+    }
+
+    .ss-row-right {
+      justify-content: flex-end;
+
+      .kefu-message {
+        margin-right: 20px;
+        position: relative;
+
+        &::after {
+          content: '';
+          width: 10px;
+          height: 10px;
+          right: -19px;
+          top: calc(50% - 10px);
+          position: absolute;
+          border-left: 5px solid #ffffff;
+          border-bottom: 5px solid transparent;
+          border-top: 5px solid transparent;
+          border-right: 5px solid transparent;
+        }
+      }
+    }
+
+    // 消息气泡
+    .kefu-message {
+      color: #333;
+      border-radius: 5px;
+      box-shadow: 3px 5px 15px rgba(0, 0, 0, 0.2);
+      padding: 5px 10px;
+      width: auto;
+      max-width: 50%;
+      text-align: left;
+      display: inline-block !important;
+      position: relative;
+      word-break: break-all;
+      background-color: #ffffff;
+      transition: all 0.2s;
+
+      &:hover {
+        transform: scale(1.03);
+      }
+    }
+
+    .date-message,
+    .system-message {
+      width: fit-content;
+      border-radius: 12rpx;
+      padding: 8rpx 16rpx;
+      margin-bottom: 16rpx;
+      background-color: #e8e8e8;
+      color: #999;
+      font-size: 24rpx;
+    }
+  }
+
+  .chat-tools {
+    width: 100%;
+    border: #e4e0e0 solid 1px;
+    border-radius: 10px;
+    height: 44px;
+  }
+
+  ::v-deep(textarea) {
+    resize: none;
+  }
+}
+</style>

二進制
src/views/mall/promotion/kefu/components/asserts/a.png


二進制
src/views/mall/promotion/kefu/components/asserts/aini.png


二進制
src/views/mall/promotion/kefu/components/asserts/aixin.png


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