فهرست منبع

Merge branch 'master' of https://gitee.com/yudaocode/yudao-ui-admin-vue3 into feature/bpm

YunaiV 1 سال پیش
والد
کامیت
5376eb4051
100فایلهای تغییر یافته به همراه11679 افزوده شده و 3634 حذف شده
  1. 5 0
      .env
  2. 0 1
      .gitignore
  3. BIN
      .image/common/ai-feature.png
  4. BIN
      .image/common/ai-preview.gif
  5. 16 19
      README.md
  6. 11 1
      build/vite/optimize.ts
  7. 7 3
      package.json
  8. 5750 924
      pnpm-lock.yaml
  9. 5 4
      src/api/ai/chat/conversation/index.ts
  10. 7 12
      src/api/ai/chat/message/index.ts
  11. 62 57
      src/api/ai/image/index.ts
  12. 60 0
      src/api/ai/mindmap/index.ts
  13. 41 0
      src/api/ai/music/index.ts
  14. 85 0
      src/api/ai/write/index.ts
  15. 1 1
      src/api/bpm/definition/index.ts
  16. 1 0
      src/api/bpm/model/index.ts
  17. 2 11
      src/api/bpm/processInstance/index.ts
  18. 1 0
      src/api/infra/jobLog/index.ts
  19. 10 0
      src/api/mall/product/history.ts
  20. 35 0
      src/api/mall/promotion/kefu/conversation/index.ts
  21. 36 0
      src/api/mall/promotion/kefu/message/index.ts
  22. 0 1
      src/assets/ai/clear.svg
  23. BIN
      src/assets/ai/dall2.jpg
  24. BIN
      src/assets/ai/dall3.jpg
  25. BIN
      src/assets/ai/qingxi.jpg
  26. BIN
      src/assets/ai/ziran.jpg
  27. BIN
      src/assets/audio/response.mp3
  28. 1 1
      src/components/ConfigGlobal/src/ConfigGlobal.vue
  29. 4 5
      src/components/ContentWrap/src/ContentWrap.vue
  30. 60 30
      src/components/DictTag/src/DictTag.vue
  31. 1 0
      src/components/DiyEditor/components/ComponentLibrary.vue
  32. 49 9
      src/components/Editor/src/Editor.vue
  33. 23 54
      src/components/MarkdownView/index.vue
  34. 1 0
      src/components/UploadFile/src/UploadFile.vue
  35. 1 1
      src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue
  36. 9 7
      src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue
  37. 10 8
      src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue
  38. 3 1
      src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue
  39. 2 1
      src/config/axios/service.ts
  40. 5 0
      src/layout/components/Menu/src/Menu.vue
  41. 1 1
      src/layout/components/UserInfo/src/components/LockPage.vue
  42. 21 1
      src/router/modules/remaining.ts
  43. 8 0
      src/utils/constants.ts
  44. 9 2
      src/utils/dict.ts
  45. 34 1
      src/utils/download.ts
  46. 1 1
      src/utils/index.ts
  47. 3 3
      src/views/Login/components/LoginForm.vue
  48. 104 153
      src/views/ai/chat/index/components/conversation/ConversationList.vue
  49. 7 3
      src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue
  50. 52 92
      src/views/ai/chat/index/components/message/MessageList.vue
  51. 15 11
      src/views/ai/chat/index/components/message/MessageListEmpty.vue
  52. 0 0
      src/views/ai/chat/index/components/message/MessageLoading.vue
  53. 9 13
      src/views/ai/chat/index/components/message/MessageNewConversation.vue
  54. 13 7
      src/views/ai/chat/index/components/role/RoleCategoryList.vue
  55. 0 0
      src/views/ai/chat/index/components/role/RoleHeader.vue
  56. 18 32
      src/views/ai/chat/index/components/role/RoleList.vue
  57. 43 50
      src/views/ai/chat/index/components/role/RoleRepository.vue
  58. 355 405
      src/views/ai/chat/index/index.vue
  59. 1 1
      src/views/ai/chat/manager/index.vue
  60. 0 145
      src/views/ai/image/ImageDetailDrawer.vue
  61. 0 210
      src/views/ai/image/ImageTask.vue
  62. 0 135
      src/views/ai/image/ImageTaskCard.vue
  63. 0 398
      src/views/ai/image/dall3/index.vue
  64. 0 105
      src/views/ai/image/index.vue
  65. 162 0
      src/views/ai/image/index/components/ImageCard.vue
  66. 224 0
      src/views/ai/image/index/components/ImageDetail.vue
  67. 245 0
      src/views/ai/image/index/components/ImageList.vue
  68. 320 0
      src/views/ai/image/index/components/dall3/index.vue
  69. 326 0
      src/views/ai/image/index/components/midjourney/index.vue
  70. 216 0
      src/views/ai/image/index/components/other/index.vue
  71. 272 0
      src/views/ai/image/index/components/stableDiffusion/index.vue
  72. 141 0
      src/views/ai/image/index/index.vue
  73. 251 0
      src/views/ai/image/manager/index.vue
  74. 0 388
      src/views/ai/image/midjourney/index.vue
  75. 104 0
      src/views/ai/image/square/index.vue
  76. 0 297
      src/views/ai/image/stable-diffusion/index.vue
  77. 78 0
      src/views/ai/mindmap/index/components/Left.vue
  78. 163 0
      src/views/ai/mindmap/index/components/Right.vue
  79. 92 0
      src/views/ai/mindmap/index/index.vue
  80. 189 0
      src/views/ai/mindmap/manager/index.vue
  81. 18 25
      src/views/ai/model/chatRole/ChatRoleForm.vue
  82. 26 0
      src/views/ai/music/index/index.vue
  83. 70 0
      src/views/ai/music/index/list/audioBar/index.vue
  84. 108 0
      src/views/ai/music/index/list/index.vue
  85. 36 0
      src/views/ai/music/index/list/songCard/index.vue
  86. 22 0
      src/views/ai/music/index/list/songInfo/index.vue
  87. 55 0
      src/views/ai/music/index/mode/desc.vue
  88. 41 0
      src/views/ai/music/index/mode/index.vue
  89. 83 0
      src/views/ai/music/index/mode/lyric.vue
  90. 25 0
      src/views/ai/music/index/title/index.vue
  91. 292 0
      src/views/ai/music/manager/index.vue
  92. 409 0
      src/views/ai/utils/constants.ts
  93. 13 0
      src/views/ai/utils/utils.ts
  94. 213 0
      src/views/ai/write/index/components/Left.vue
  95. 120 0
      src/views/ai/write/index/components/Right.vue
  96. 32 0
      src/views/ai/write/index/components/Tag.vue
  97. 76 0
      src/views/ai/write/index/index.vue
  98. 256 0
      src/views/ai/write/manager/index.vue
  99. 0 1
      src/views/bpm/category/index.vue
  100. 3 3
      src/views/bpm/oa/leave/index.vue

+ 5 - 0
.env

@@ -18,3 +18,8 @@ VITE_APP_DOCALERT_ENABLE=true
 
 # 百度统计
 VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc
+
+# 默认账户密码
+VITE_APP_DEFAULT_LOGIN_TENANT = 芋道源码
+VITE_APP_DEFAULT_LOGIN_USERNAME = admin
+VITE_APP_DEFAULT_LOGIN_PASSWORD = admin123

+ 0 - 1
.gitignore

@@ -2,7 +2,6 @@ node_modules
 .DS_Store
 dist
 dist-ssr
-*.local
 /dist*
 pnpm-debug
 auto-*.d.ts

BIN
.image/common/ai-feature.png


BIN
.image/common/ai-preview.gif


+ 16 - 19
README.md

@@ -54,17 +54,16 @@
 
 推荐 VS Code 开发,配合插件如下:
 
-| 插件名                           | 功能                       |
-|-------------------------------|--------------------------|
-| TypeScript Vue Plugin (Volar) | 用于 TypeScript 的 Vue 插件  |
-| Vue Language Features (Volar) | Vue3.0 语法支持              |
-| unocss                        | unocss for vscode           |
-| Iconify IntelliSense          | Iconify 预览和搜索           |
-| i18n Ally                     | 国际化智能提示               |
-| Stylelint                     | Css    格式化               |
-| Prettier                      | 代码格式化                   |
-| ESLint                        | 脚本代码检查                  |
-| DotENV                        | env 文件高亮                 |
+| 插件名                           | 功能                  |
+|-------------------------------|---------------------|
+| Vue - Official                | Vue 与 TypeScript 支持 |
+| unocss                        | unocss for vscode   |
+| Iconify IntelliSense          | Iconify 预览和搜索       |
+| i18n Ally                     | 国际化智能提示             |
+| Stylelint                     | Css    格式化          |
+| Prettier                      | 代码格式化               |
+| ESLint                        | 脚本代码检查              |
+| DotENV                        | env 文件高亮            |
 
 ## 🔥 后端架构
 
@@ -192,26 +191,24 @@ ps:核心功能已经实现,正在对接微信小程序中...
 
 ### 商城系统
 
+演示地址:<https://doc.iocoder.cn/mall-preview/>
+
 ![功能图](/.image/common/mall-feature.png)
 
 ![功能图](/.image/common/mall-preview.png)
 
-_前端基于 crmeb uniapp 经过授权重构,优化代码实现,接入芋道快速开发平台_
-
-演示地址:<https://doc.iocoder.cn/mall-preview/>
-
 ### ERP 系统
 
-![功能图](/.image/common/erp-feature.png)
-
 演示地址:<https://doc.iocoder.cn/erp-preview/>
 
-### CRM 系统
+![功能图](/.image/common/erp-feature.png)
 
-![功能图](/.image/common/crm-feature.png)
+### CRM 系统
 
 演示地址:<https://doc.iocoder.cn/crm-preview/>
 
+![功能图](/.image/common/crm-feature.png)
+
 ## 🐷 演示图
 
 ### 系统功能

+ 11 - 1
build/vite/optimize.ts

@@ -27,6 +27,12 @@ const include = [
   'echarts-wordcloud',
   '@wangeditor/editor',
   '@wangeditor/editor-for-vue',
+  '@microsoft/fetch-event-source',
+  'markdown-it',
+  'markmap-view',
+  'markmap-lib',
+  'markmap-toolbar',
+  'highlight.js',
   'element-plus',
   'element-plus/es',
   'element-plus/es/locale/lang/zh-cn',
@@ -104,7 +110,11 @@ const include = [
   'element-plus/es/components/collapse/style/css',
   'element-plus/es/components/collapse-item/style/css',
   'element-plus/es/components/button-group/style/css',
-  'element-plus/es/components/text/style/css'
+  'element-plus/es/components/text/style/css',
+  'element-plus/es/components/segmented/style/css',
+  '@element-plus/icons-vue',
+  'element-plus/es/components/footer/style/css',
+  'element-plus/es/components/empty/style/css'
 ]
 
 const exclude = ['@iconify/json']

+ 7 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yudao-ui-admin-vue3",
-  "version": "2.1.0-snapshot",
+  "version": "2.2.0-snapshot",
   "description": "基于vue3、vite4、element-plus、typesScript",
   "author": "xingyu",
   "private": false,
@@ -52,7 +52,11 @@
     "highlight.js": "^11.9.0",
     "jsencrypt": "^3.3.2",
     "lodash-es": "^4.17.21",
-    "marked": "^12.0.2",
+    "markdown-it": "^14.1.0",
+    "markmap-common": "^0.16.0",
+    "markmap-lib": "^0.16.1",
+    "markmap-toolbar": "^0.17.0",
+    "markmap-view": "^0.16.0",
     "min-dash": "^4.1.1",
     "mitt": "^3.0.1",
     "nprogress": "^0.2.0",
@@ -85,8 +89,8 @@
     "@types/qs": "^6.9.12",
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
-    "@unocss/transformer-variant-group": "^0.58.5",
     "@unocss/eslint-config": "^0.57.4",
+    "@unocss/transformer-variant-group": "^0.58.5",
     "@vitejs/plugin-legacy": "^5.3.1",
     "@vitejs/plugin-vue": "^5.0.4",
     "@vitejs/plugin-vue-jsx": "^3.1.0",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5750 - 924
pnpm-lock.yaml


+ 5 - 4
src/api/ai/chat/conversation/index.ts

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

+ 7 - 12
src/api/ai/chat/message/index.ts

@@ -19,23 +19,18 @@ export interface ChatMessageVO {
   userAvatar: string // 创建时间
 }
 
-export interface ChatMessageSendVO {
-  conversationId: string // 对话编号
-  content: number // 聊天内容
-}
-
 // AI chat 聊天
 export const ChatMessageApi = {
   // 消息列表
-  messageList: async (conversationId: string | null) => {
+  getChatMessageListByConversationId: async (conversationId: number | null) => {
     return await request.get({
       url: `/ai/chat/message/list-by-conversation-id?conversationId=${conversationId}`
     })
   },
 
-  // 发送 send stream 消息
-  // TODO axios 可以么? https://apifox.com/apiskills/how-to-create-axios-stream/
-  sendStream: async (
+  // 发送 Stream 消息
+  // 为什么不用 axios 呢?因为它不支持 SSE 调用
+  sendChatMessageStream: async (
     conversationId: number,
     content: string,
     ctrl,
@@ -65,12 +60,12 @@ export const ChatMessageApi = {
   },
 
   // 删除消息
-  delete: async (id: string) => {
+  deleteChatMessage: async (id: string) => {
     return await request.delete({ url: `/ai/chat/message/delete?id=${id}` })
   },
 
-  // 删除消息 - 对话所有消息
-  deleteByConversationId: async (conversationId: string) => {
+  // 删除指定对话的消息
+  deleteByConversationId: async (conversationId: number) => {
     return await request.delete({
       url: `/ai/chat/message/delete-by-conversation-id?conversationId=${conversationId}`
     })

+ 62 - 57
src/api/ai/image/index.ts

@@ -1,49 +1,22 @@
 import request from '@/config/axios'
 
-// AI API 密钥 VO
-// TODO @fan:要不前端不弄太多 VO,就用这个 ImageDetailVO?!
-export interface ImageDetailVO {
+// AI 绘图 VO
+export interface ImageVO {
   id: number // 编号
-  prompt: string // 提示词
-  status: number // 状态
-  errorMessage: string // 错误信息
-  type: string // 模型下分不同的类型(清晰、真实...)
-  taskId: number // dr 任务id
-  picUrl: string // 任务地址
-  originalPicUrl: string // 绘制图片地址
   platform: string // 平台
   model: string // 模型
-  style: string // 图像生成的风格
-  size: string // 图片尺寸
-  buttons: ImageMjButtonsVO[] // mj 操作按钮
-  createTime: string // 创建时间
-  updateTime: string // 更新事件
-}
-
-export interface ImageMjButtonsVO {
-  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
-  emoji: string // 图标 emoji
-  label: string // Make Variations 文本
-  style: number // 样式: 2(Primary)、3(Green)
-}
-
-export interface ImageMjActionVO {
-  id: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
-  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
-}
-
-
-export interface ImagePageReqVO {
-  pageNo: number // 分页编号
-  pageSize: number // 分页大小
-}
-
-export interface ImageDallReqVO {
   prompt: string // 提示词
-  model: string // 模型
-  style: string // 图像生成的风格
-  width: string // 图片宽度
-  height: string // 图片高度
+  width: number // 图片宽度
+  height: number // 图片高度
+  status: number // 状态
+  publicStatus: boolean // 公开状态
+  picUrl: string // 任务地址
+  errorMessage: string // 错误信息
+  options: any // 配置 Map<string, string>
+  taskId: number // 任务编号
+  buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮
+  createTime: Date // 创建时间
+  finishTime: Date // 完成时间
 }
 
 export interface ImageDrawReqVO {
@@ -65,34 +38,66 @@ export interface ImageMidjourneyImagineReqVO {
   version: string // 版本
 }
 
-// TODO 芋艿:review 下整体注释、方法名
-// AI API 密钥 API
+export interface ImageMidjourneyActionVO {
+  id: number // 图片编号
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+}
+
+export interface ImageMidjourneyButtonsVO {
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+  emoji: string // 图标 emoji
+  label: string // Make Variations 文本
+  style: number // 样式: 2(Primary)、3(Green)
+}
+
+// AI 图片 API
 export const ImageApi = {
-  // 获取 image 列表
-  getImageList: async (params: ImagePageReqVO) => {
+  // 获取【我的】绘图分页
+  getImagePageMy: async (params: any) => {
     return await request.get({ url: `/ai/image/my-page`, params })
   },
-  // 获取 image 详细信息
-  getImageDetail: async (id: number) => {
-    return await request.get({ url: `/ai/image/get-my?id=${id}`})
+  // 获取【我的】绘图记录
+  getImageMy: async (id: number) => {
+    return await request.get({ url: `/ai/image/get-my?id=${id}` })
+  },
+  // 获取【我的】绘图记录列表
+  getImageListMyByIds: async (ids: number[]) => {
+    return await request.get({ url: `/ai/image/my-list-by-ids`, params: { ids: ids.join(',') } })
   },
   // 生成图片
-  drawImage: async (data: ImageDrawReqVO)=> {
+  drawImage: async (data: ImageDrawReqVO) => {
     return await request.post({ url: `/ai/image/draw`, data })
   },
-  // 删除
-  deleteImage: async (id: number)=> {
-    return await request.delete({ url: `/ai/image/delete-my?id=${id}`})
+  // 删除【我的】绘画记录
+  deleteImageMy: async (id: number) => {
+    return await request.delete({ url: `/ai/image/delete-my?id=${id}` })
   },
 
-  // ------------ midjourney
+  // ================ midjourney 专属 ================
 
-  // midjourney - imagine
-  midjourneyImagine: async (data: ImageMidjourneyImagineReqVO)=> {
+  // 【Midjourney】生成图片
+  midjourneyImagine: async (data: ImageMidjourneyImagineReqVO) => {
     return await request.post({ url: `/ai/image/midjourney/imagine`, data })
   },
-  // midjourney - action
-  midjourneyAction: async (params: ImageMjActionVO)=> {
-    return await request.get({ url: `/ai/image/midjourney/action`, params })
+  // 【Midjourney】Action 操作(二次生成图片)
+  midjourneyAction: async (data: ImageMidjourneyActionVO) => {
+    return await request.post({ url: `/ai/image/midjourney/action`, data })
+  },
+
+  // ================ 绘图管理 ================
+
+  // 查询绘画分页
+  getImagePage: async (params: any) => {
+    return await request.get({ url: `/ai/image/page`, params })
   },
+
+  // 更新绘画发布状态
+  updateImage: async (data: any) => {
+    return await request.put({ url: '/ai/image/update', data })
+  },
+
+  // 删除绘画
+  deleteImage: async (id: number) => {
+    return await request.delete({ url: `/ai/image/delete?id=` + id })
+  }
 }

+ 60 - 0
src/api/ai/mindmap/index.ts

@@ -0,0 +1,60 @@
+import { getAccessToken } from '@/utils/auth'
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+import { config } from '@/config/axios/config'
+import request from '@/config/axios'
+
+// AI 思维导图 VO
+export interface MindMapVO {
+  id: number // 编号
+  userId: number // 用户编号
+  prompt: string // 生成内容提示
+  generatedContent: string // 生成的思维导图内容
+  platform: string // 平台
+  model: string // 模型
+  errorMessage: string // 错误信息
+}
+
+// AI 思维导图生成 VO
+export interface AiMindMapGenerateReqVO {
+  prompt: string
+}
+
+export const AiMindMapApi = {
+  generateMindMap: ({
+    data,
+    onClose,
+    onMessage,
+    onError,
+    ctrl
+  }: {
+    data: AiMindMapGenerateReqVO
+    onMessage?: (res: any) => void
+    onError?: (...args: any[]) => void
+    onClose?: (...args: any[]) => void
+    ctrl: AbortController
+  }) => {
+    const token = getAccessToken()
+    return fetchEventSource(`${config.base_url}/ai/mind-map/generate-stream`, {
+      method: 'post',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      openWhenHidden: true,
+      body: JSON.stringify(data),
+      onmessage: onMessage,
+      onerror: onError,
+      onclose: onClose,
+      signal: ctrl.signal
+    })
+  },
+
+  // 查询思维导图分页
+  getMindMapPage: async (params: any) => {
+    return await request.get({ url: `/ai/mind-map/page`, params })
+  },
+  // 删除思维导图
+  deleteMindMap: async (id: number) => {
+    return await request.delete({ url: `/ai/mind-map/delete?id=` + id })
+  }
+}

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

+ 85 - 0
src/api/ai/write/index.ts

@@ -0,0 +1,85 @@
+import { fetchEventSource } from '@microsoft/fetch-event-source'
+
+import { getAccessToken } from '@/utils/auth'
+import { config } from '@/config/axios/config'
+import { AiWriteTypeEnum } from '@/views/ai/utils/constants'
+import request from '@/config/axios'
+
+export interface WriteVO {
+  type: AiWriteTypeEnum.WRITING | AiWriteTypeEnum.REPLY // 1:撰写 2:回复
+  prompt: string // 写作内容提示 1。撰写 2回复
+  originalContent: string // 原文
+  length: number // 长度
+  format: number // 格式
+  tone: number // 语气
+  language: number // 语言
+  userId?: number // 用户编号
+  platform?: string // 平台
+  model?: string // 模型
+  generatedContent?: string // 生成的内容
+  errorMessage?: string // 错误信息
+  createTime?: Date // 创建时间
+}
+
+export interface AiWritePageReqVO extends PageParam {
+  userId?: number // 用户编号
+  type?: AiWriteTypeEnum //  写作类型
+  platform?: string // 平台
+  createTime?: [string, string] // 创建时间
+}
+
+export interface AiWriteRespVo {
+  id: number
+  userId: number
+  type: number
+  platform: string
+  model: string
+  prompt: string
+  generatedContent: string
+  originalContent: string
+  length: number
+  format: number
+  tone: number
+  language: number
+  errorMessage: string
+  createTime: string
+}
+
+export const WriteApi = {
+  writeStream: ({
+    data,
+    onClose,
+    onMessage,
+    onError,
+    ctrl
+  }: {
+    data: WriteVO
+    onMessage?: (res: any) => void
+    onError?: (...args: any[]) => void
+    onClose?: (...args: any[]) => void
+    ctrl: AbortController
+  }) => {
+    const token = getAccessToken()
+    return fetchEventSource(`${config.base_url}/ai/write/generate-stream`, {
+      method: 'post',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      openWhenHidden: true,
+      body: JSON.stringify(data),
+      onmessage: onMessage,
+      onerror: onError,
+      onclose: onClose,
+      signal: ctrl.signal
+    })
+  },
+  // 获取写作列表
+  getWritePage: (params: AiWritePageReqVO) => {
+    return request.get<PageResult<AiWriteRespVo[]>>({ url: `/ai/write/page`, params })
+  },
+  // 删除写作
+  deleteWrite(id: number) {
+    return request.delete({ url: `/ai/write/delete`, params: { id } })
+  }
+}

+ 1 - 1
src/api/bpm/definition/index.ts

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 
-export const getProcessDefinition = async (id: number, key: string) => {
+export const getProcessDefinition = async (id?: string, key?: string) => {
   return await request.get({
     url: '/bpm/process-definition/get',
     params: { id, key }

+ 1 - 0
src/api/bpm/model/index.ts

@@ -5,6 +5,7 @@ export type ProcessDefinitionVO = {
   version: number
   deploymentTIme: string
   suspensionState: number
+  formType?: number
 }
 
 export type ModelVO = {

+ 2 - 11
src/api/bpm/processInstance/index.ts

@@ -1,4 +1,5 @@
 import request from '@/config/axios'
+import { ProcessDefinitionVO } from '@/api/bpm/model'
 
 export type Task = {
   id: string
@@ -18,17 +19,7 @@ export type ProcessInstanceVO = {
   businessKey: string
   createTime: string
   endTime: string
-}
-
-export type ProcessInstanceCopyVO = {
-  type: number
-  taskName: string
-  taskKey: string
-  processInstanceName: string
-  processInstanceKey: string
-  startUserId: string
-  options: string[]
-  reason: string
+  processDefinition?: ProcessDefinitionVO
 }
 
 export const getProcessInstanceMyPage = async (params: any) => {

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

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

+ 10 - 0
src/api/mall/product/history.ts

@@ -0,0 +1,10 @@
+import request from '@/config/axios'
+
+/**
+ * 获得商品浏览记录分页
+ *
+ * @param params 请求参数
+ */
+export const getBrowseHistoryPage = (params: any) => {
+  return request.get({ url: '/product/browse-history/page', params })
+}

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

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

@@ -1 +0,0 @@
-<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1716342375293" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2604" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M899.1 869.6l-53-305.6H864c14.4 0 26-11.6 26-26V346c0-14.4-11.6-26-26-26H618V138c0-14.4-11.6-26-26-26H432c-14.4 0-26 11.6-26 26v182H160c-14.4 0-26 11.6-26 26v192c0 14.4 11.6 26 26 26h17.9l-53 305.6c-0.3 1.5-0.4 3-0.4 4.4 0 14.4 11.6 26 26 26h723c1.5 0 3-0.1 4.4-0.4 14.2-2.4 23.7-15.9 21.2-30zM204 390h272V182h72v208h272v104H204V390z m468 440V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H416V674c0-4.4-3.6-8-8-8h-48c-4.4 0-8 3.6-8 8v156H202.8l45.1-260H776l45.1 260H672z" p-id="2605" fill="#8a8a8a"></path></svg>

BIN
src/assets/ai/dall2.jpg


BIN
src/assets/ai/dall3.jpg


BIN
src/assets/ai/qingxi.jpg


BIN
src/assets/ai/ziran.jpg


BIN
src/assets/audio/response.mp3


+ 1 - 1
src/components/ConfigGlobal/src/ConfigGlobal.vue

@@ -54,7 +54,7 @@ const currentLocale = computed(() => localeStore.currentLocale)
   <ElConfigProvider
     :namespace="variables.elNamespace"
     :locale="currentLocale.elLocale"
-    :message="{ max: 1 }"
+    :message="{ max: 5 }"
     :size="size"
   >
     <slot></slot>

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

+ 60 - 30
src/components/DictTag/src/DictTag.vue

@@ -1,8 +1,9 @@
 <script lang="tsx">
-import { defineComponent, PropType, ref } from 'vue'
+import { defineComponent, PropType, computed } from 'vue'
 import { isHexColor } from '@/utils/color'
 import { ElTag } from 'element-plus'
 import { DictDataType, getDictOptions } from '@/utils/dict'
+import { isArray, isString, isNumber } from '@/utils/is'
 
 export default defineComponent({
   name: 'DictTag',
@@ -12,49 +13,78 @@ export default defineComponent({
       required: true
     },
     value: {
-      type: [String, Number, Boolean] as PropType<string | number | boolean>,
+      type: [String, Number, Boolean, Array],
       required: true
+    },
+    // 字符串分隔符 只有当 props.value 传入值为字符串时有效
+    separator: {
+      type: String as PropType<string>,
+      default: ','
+    },
+    // 每个 tag 之间的间隔,默认为 5px,参考的 el-row 的 gutter
+    gutter: {
+      type: String as PropType<string>,
+      default: '5px'
     }
   },
   setup(props) {
-    const dictData = ref<DictDataType>()
-    const getDictObj = (dictType: string, value: string) => {
-      const dictOptions = getDictOptions(dictType)
-      dictOptions.forEach((dict: DictDataType) => {
-        if (dict.value === value) {
-          if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') {
-            dict.colorType = ''
-          }
-          dictData.value = dict
-        }
-      })
-    }
-    const rederDictTag = () => {
+    const valueArr: any = computed(() => {
+      // 1.是Number类型的情况
+      if (isNumber(props.value)) {
+        return [String(props.value)]
+      }
+      // 2.是字符串(进一步判断是否有包含分隔符号 -> props.sepSymbol )
+      else if (isString(props.value)) {
+        return props.value.split(props.separator)
+      }
+      // 3.数组
+      else if (isArray(props.value)) {
+        return props.value.map(String)
+      }
+      return []
+    })
+    const renderDictTag = () => {
       if (!props.type) {
         return null
       }
       // 解决自定义字典标签值为零时标签不渲染的问题
-      if (props.value === undefined || props.value === null) {
+      if (props.value === undefined || props.value === null || props.value === '') {
         return null
       }
-      getDictObj(props.type, props.value.toString())
-      // 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题
+      const dictOptions = getDictOptions(props.type)
+
       return (
-        <ElTag
-          style={dictData.value?.cssClass ? 'color: #fff' : ''}
-          type={dictData.value?.colorType}
-          color={
-            dictData.value?.cssClass && isHexColor(dictData.value?.cssClass)
-              ? dictData.value?.cssClass
-              : ''
-          }
-          disableTransitions={true}
+        <div
+          class="dict-tag"
+          style={{
+            display: 'flex',
+            gap: props.gutter,
+            justifyContent: 'center',
+            alignItems: 'center'
+          }}
         >
-          {dictData.value?.label}
-        </ElTag>
+          {dictOptions.map((dict: DictDataType) => {
+            if (valueArr.value.includes(dict.value)) {
+              if (dict.colorType + '' === 'primary' || dict.colorType + '' === 'default') {
+                dict.colorType = ''
+              }
+              return (
+                // 添加标签的文字颜色为白色,解决自定义背景颜色时标签文字看不清的问题
+                <ElTag
+                  style={dict?.cssClass ? 'color: #fff' : ''}
+                  type={dict?.colorType}
+                  color={dict?.cssClass && isHexColor(dict?.cssClass) ? dict?.cssClass : ''}
+                  disableTransitions={true}
+                >
+                  {dict?.label}
+                </ElTag>
+              )
+            }
+          })}
+        </div>
       )
     }
-    return () => rederDictTag()
+    return () => renderDictTag()
   }
 })
 </script>

+ 1 - 0
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -95,6 +95,7 @@ const handleCloneComponent = (component: DiyComponent<any>) => {
 .editor-left {
   z-index: 1;
   flex-shrink: 0;
+  user-select: none;
   box-shadow: 8px 0 8px -8px rgb(0 0 0 / 12%);
 
   :deep(.el-collapse) {

+ 49 - 9
src/components/Editor/src/Editor.vue

@@ -96,11 +96,6 @@ const editorConfig = computed((): IEditorConfig => {
           // 选择文件时的类型限制,默认为 ['image/*'] 。如不想限制,则设置为 []
           allowedFileTypes: ['image/*'],
 
-          // 自定义上传参数,例如传递验证的 token 等。参数会被添加到 formData 中,一起上传到服务端。
-          meta: { updateSupport: 0 },
-          // 将 meta 拼接到 url 参数中,默认 false
-          metaWithUrl: true,
-
           // 自定义增加 http  header
           headers: {
             Accept: '*',
@@ -108,9 +103,6 @@ const editorConfig = computed((): IEditorConfig => {
             'tenant-id': getTenantId()
           },
 
-          // 跨域是否传递 cookie ,默认为 false
-          withCredentials: true,
-
           // 超时时间,默认为 10 秒
           timeout: 5 * 1000, // 5 秒
 
@@ -119,7 +111,7 @@ const editorConfig = computed((): IEditorConfig => {
 
           // 上传之前触发
           onBeforeUpload(file: File) {
-            console.log(file)
+            // console.log(file)
             return file
           },
           // 上传进度的回调函数
@@ -142,6 +134,54 @@ const editorConfig = computed((): IEditorConfig => {
           customInsert(res: any, insertFn: InsertFnType) {
             insertFn(res.data, 'image', res.data)
           }
+        },
+        ['uploadVideo']: {
+          server: import.meta.env.VITE_UPLOAD_URL,
+          // 单个文件的最大体积限制,默认为 10M
+          maxFileSize: 10 * 1024 * 1024,
+          // 最多可上传几个文件,默认为 100
+          maxNumberOfFiles: 10,
+          // 选择文件时的类型限制,默认为 ['video/*'] 。如不想限制,则设置为 []
+          allowedFileTypes: ['video/*'],
+
+          // 自定义增加 http  header
+          headers: {
+            Accept: '*',
+            Authorization: 'Bearer ' + getAccessToken(),
+            'tenant-id': getTenantId()
+          },
+
+          // 超时时间,默认为 30 秒
+          timeout: 15 * 1000, // 15 秒
+
+          // form-data fieldName,后端接口参数名称,默认值wangeditor-uploaded-image
+          fieldName: 'file',
+
+          // 上传之前触发
+          onBeforeUpload(file: File) {
+            // console.log(file)
+            return file
+          },
+          // 上传进度的回调函数
+          onProgress(progress: number) {
+            // progress 是 0-100 的数字
+            console.log('progress', progress)
+          },
+          onSuccess(file: File, res: any) {
+            console.log('onSuccess', file, res)
+          },
+          onFailed(file: File, res: any) {
+            alert(res.message)
+            console.log('onFailed', file, res)
+          },
+          onError(file: File, err: any, res: any) {
+            alert(err.message)
+            console.error('onError', file, err, res)
+          },
+          // 自定义插入图片
+          customInsert(res: any, insertFn: InsertFnType) {
+            insertFn(res.data, 'mp4', res.data)
+          }
         }
       },
       uploadImgShowBase64: true

+ 23 - 54
src/components/MarkdownView/index.vue

@@ -1,43 +1,12 @@
-
 <template>
-  <div ref="contentRef" class="markdown-view" v-html="contentHtml"></div>
+  <div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
 </template>
 
 <script setup lang="ts">
-import {useClipboard} from "@vueuse/core";
-
-import {marked} from 'marked'
+import { useClipboard } from '@vueuse/core'
+import MarkdownIt from 'markdown-it'
 import 'highlight.js/styles/vs2015.min.css'
 import hljs from 'highlight.js'
-import {ref} from "vue";
-
-const {copy} = useClipboard() // 初始化 copy 到粘贴板
-const contentRef = ref()
-
-// 代码高亮:https://highlightjs.org/
-// 转换 markdown:marked
-
-// marked 渲染器
-const renderer = {
-  code(code, language, c) {
-    let highlightHtml
-    try {
-      highlightHtml = hljs.highlight(code, {language: language, ignoreIllegals: true}).value
-    } catch (e) {
-      // skip
-    }
-    const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
-    return `<pre style="position: relative;">${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
-  }
-}
-
-// 配置 marked
-marked.use({
-  renderer: renderer
-})
-
-// 渲染的html内容
-const contentHtml = ref<any>()
 
 // 定义组件属性
 const props = defineProps({
@@ -47,39 +16,39 @@ const props = defineProps({
   }
 })
 
-// 将 props 变为引用类型
-const { content } = toRefs(props)
+const message = useMessage() // 消息弹窗
+const { copy } = useClipboard() // 初始化 copy 到粘贴板
+const contentRef = ref()
 
-// 监听 content 变化
-watch(content, async (newValue, oldValue) => {
-  await renderMarkdown(newValue);
+const md = new MarkdownIt({
+  highlight: function (str, lang) {
+    if (lang && hljs.getLanguage(lang)) {
+      try {
+        const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
+        return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>`
+      } catch (__) {}
+    }
+    return ``
+  }
 })
 
-// 渲染 markdown
-const renderMarkdown = async (content: string) => {
-  contentHtml.value = await marked(content)
-}
+/** 渲染 markdown */
+const renderedMarkdown = computed(() => {
+  return md.render(props.content)
+})
 
-// 组件挂在时
-onMounted(async ()  => {
-  // 解析转换 markdown
-  await renderMarkdown(props.content as string);
-  //
+/** 初始化 **/
+onMounted(async () => {
   // 添加 copy 监听
   contentRef.value.addEventListener('click', (e: any) => {
-    console.log(e)
     if (e.target.id === 'copy') {
       copy(e.target?.dataset?.copy)
-      ElMessage({
-        message: '复制成功!',
-        type: 'success'
-      })
+      message.success('复制成功!')
     }
   })
 })
 </script>
 
-
 <style lang="scss">
 .markdown-view {
   font-family: PingFang SC;

+ 1 - 0
src/components/UploadFile/src/UploadFile.vue

@@ -32,6 +32,7 @@
           格式为 <b style="color: #f56c6c">{{ fileType.join('/') }}</b> 的文件
         </div>
       </template>
+      <!-- TODO @puhui999:1)表单展示的时候,位置会偏掉,已发微信;2)disable 的时候,应该把【删除】按钮也隐藏掉? -->
       <template #file="row">
         <div class="flex items-center">
           <span>{{ row.file.name }}</span>

+ 1 - 1
src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue

@@ -129,7 +129,7 @@ const updateFlowType = (flowType) => {
       conditionExpression: null
     })
     bpmnInstances().modeling.updateProperties(toRaw(bpmnElementSource.value), {
-      default: bpmnElement.value
+      default: toRaw(bpmnElement.value)
     })
     return
   }

+ 9 - 7
src/components/bpmnProcessDesigner/package/penal/listeners/ProcessListenerDialog.vue

@@ -43,9 +43,6 @@ import { CommonStatusEnum } from '@/utils/constants'
 /** BPM 流程 表单 */
 defineOptions({ name: 'ProcessListenerDialog' })
 
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
-
 const dialogVisible = ref(false) // 弹窗的是否展示
 const loading = ref(true) // 列表的加载中
 const list = ref<ProcessListenerVO[]>([]) // 列表的数据
@@ -53,17 +50,23 @@ const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  type: undefined,
+  type: '',
   status: CommonStatusEnum.ENABLE
 })
 
 /** 打开弹窗 */
 const open = async (type: string) => {
+  queryParams.pageNo = 1
+  queryParams.type = type
+  getList()
   dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
   loading.value = true
   try {
-    queryParams.pageNo = 1
-    queryParams.type = type
     const data = await ProcessListenerApi.getProcessListenerPage(queryParams)
     list.value = data.list
     total.value = data.total
@@ -71,7 +74,6 @@ const open = async (type: string) => {
     loading.value = false
   }
 }
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 /** 提交表单 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调

+ 10 - 8
src/components/bpmnProcessDesigner/package/penal/task/task-components/ProcessExpressionDialog.vue

@@ -28,9 +28,6 @@ import { ProcessExpressionApi, ProcessExpressionVO } from '@/api/bpm/processExpr
 /** BPM 流程 表单 */
 defineOptions({ name: 'ProcessExpressionDialog' })
 
-const { t } = useI18n() // 国际化
-const message = useMessage() // 消息弹窗
-
 const dialogVisible = ref(false) // 弹窗的是否展示
 const loading = ref(true) // 列表的加载中
 const list = ref<ProcessExpressionVO[]>([]) // 列表的数据
@@ -38,17 +35,23 @@ const total = ref(0) // 列表的总页数
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  type: undefined,
+  type: '',
   status: CommonStatusEnum.ENABLE
 })
 
 /** 打开弹窗 */
-const open = async (type: string) => {
+const open = (type: string) => {
+  queryParams.pageNo = 1
+  queryParams.type = type
+  getList()
   dialogVisible.value = true
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 查询列表 */
+const getList = async () => {
   loading.value = true
   try {
-    queryParams.pageNo = 1
-    queryParams.type = type
     const data = await ProcessExpressionApi.getProcessExpressionPage(queryParams)
     list.value = data.list
     total.value = data.total
@@ -56,7 +59,6 @@ const open = async (type: string) => {
     loading.value = false
   }
 }
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
 /** 提交表单 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调

+ 3 - 1
src/components/bpmnProcessDesigner/package/penal/task/task-components/UserTask.vue

@@ -135,6 +135,7 @@ import * as PostApi from '@/api/system/post'
 import * as UserApi from '@/api/system/user'
 import * as UserGroupApi from '@/api/bpm/userGroup'
 import ProcessExpressionDialog from './ProcessExpressionDialog.vue'
+import { ProcessExpressionVO } from '@/api/bpm/processExpression'
 
 defineOptions({ name: 'UserTask' })
 const props = defineProps({
@@ -197,8 +198,9 @@ const processExpressionDialogRef = ref()
 const openProcessExpressionDialog = async () => {
   processExpressionDialogRef.value.open()
 }
-const selectProcessExpression = (expression) => {
+const selectProcessExpression = (expression: ProcessExpressionVO) => {
   userTaskForm.value.candidateParam = [expression.expression]
+  updateElementTask()
 }
 
 watch(

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

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

+ 5 - 0
src/layout/components/Menu/src/Menu.vue

@@ -90,6 +90,11 @@ export default defineComponent({
           backgroundColor="var(--left-menu-bg-color)"
           textColor="var(--left-menu-text-color)"
           activeTextColor="var(--left-menu-text-active-color)"
+          popperClass={
+            unref(menuMode) === 'vertical'
+              ? `${prefixCls}-popper--vertical`
+              : `${prefixCls}-popper--horizontal`
+          }
           onSelect={menuSelect}
         >
           {{

+ 1 - 1
src/layout/components/UserInfo/src/components/LockPage.vue

@@ -52,7 +52,7 @@ async function goLogin() {
   // 登出后清理
   deleteUserCache() // 清空用户缓存
   tagsViewStore.delAllViews()
-  resetRouter() // 重置静态路由表
+  // resetRouter() // 重置静态路由表
   lockStore.resetLockInfo()
   replace('/login')
 }

+ 21 - 1
src/router/modules/remaining.ts

@@ -341,7 +341,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
         component: () => import('@/views/mall/product/spu/form/index.vue'),
         name: 'ProductSpuAdd',
         meta: {
-          noCache: true,
+          noCache: false, // 需要缓存
           hidden: true,
           canTo: true,
           icon: 'ep:edit',
@@ -573,6 +573,26 @@ const remainingRouter: AppRouteRecordRaw[] = [
         component: () => import('@/views/crm/product/detail/index.vue')
       }
     ]
+  },
+  {
+    path: '/ai',
+    component: Layout,
+    name: 'Ai',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'image/square',
+        component: () => import('@/views/ai/image/square/index.vue'),
+        name: 'AiImageSquare',
+        meta: {
+          title: '绘图作品',
+          icon: 'ep:home-filled',
+          noCache: false
+        }
+      }
+    ]
   }
 ]
 

+ 8 - 0
src/utils/constants.ts

@@ -109,6 +109,14 @@ export const PayChannelEnum = {
     code: 'wx_app',
     name: '微信 APP 支付'
   },
+  WX_NATIVE: {
+    code: 'wx_native',
+    name: '微信 Native 支付'
+  },
+  WX_WAP: {
+    code: 'wx_wap',
+    name: '微信 WAP 网站支付'
+  },
   WX_BAR: {
     code: 'wx_bar',
     name: '微信条码支付'

+ 9 - 2
src/utils/dict.ts

@@ -125,7 +125,6 @@ export enum DICT_TYPE {
   SYSTEM_SMS_TEMPLATE_TYPE = 'system_sms_template_type',
   SYSTEM_SMS_SEND_STATUS = 'system_sms_send_status',
   SYSTEM_SMS_RECEIVE_STATUS = 'system_sms_receive_status',
-  SYSTEM_ERROR_CODE_TYPE = 'system_error_code_type',
   SYSTEM_OAUTH2_GRANT_TYPE = 'system_oauth2_grant_type',
   SYSTEM_MAIL_SEND_STATUS = 'system_mail_send_status',
   SYSTEM_NOTIFY_TEMPLATE_TYPE = 'system_notify_template_type',
@@ -219,5 +218,13 @@ export enum DICT_TYPE {
   ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型
 
   // ========== AI - 人工智能模块  ==========
-  AI_PLATFORM = 'ai_platform' // AI 平台
+  AI_PLATFORM = 'ai_platform', // AI 平台
+  AI_IMAGE_STATUS = 'ai_image_status', // AI 图片状态
+  AI_MUSIC_STATUS = 'ai_music_status', // AI 音乐状态
+  AI_GENERATE_MODE = 'ai_generate_mode', // AI 生成模式
+  AI_WRITE_TYPE = 'ai_write_type', // AI 写作类型
+  AI_WRITE_LENGTH = 'ai_write_length', // AI 写作长度
+  AI_WRITE_FORMAT = 'ai_write_format', // AI 写作格式
+  AI_WRITE_TONE = 'ai_write_tone', // AI 写作语气
+  AI_WRITE_LANGUAGE = 'ai_write_language' // AI 写作语言
 }

+ 34 - 1
src/utils/download.ts

@@ -29,9 +29,42 @@ const download = {
   html: (data: Blob, fileName: string) => {
     download0(data, fileName, 'text/html')
   },
-  // 下载 MarkdownView 方法
+  // 下载 Markdown 方法
   markdown: (data: Blob, fileName: string) => {
     download0(data, fileName, 'text/markdown')
+  },
+  // 下载图片(允许跨域)
+  image: ({
+    url,
+    canvasWidth,
+    canvasHeight,
+    drawWithImageSize = true
+  }: {
+    url: string
+    canvasWidth?: number // 指定画布宽度
+    canvasHeight?: number // 指定画布高度
+    drawWithImageSize?: boolean // 将图片绘制在画布上时带上图片的宽高值, 默认是要带上的
+  }) => {
+    const image = new Image()
+    // image.setAttribute('crossOrigin', 'anonymous')
+    image.src = url
+    image.onload = () => {
+      const canvas = document.createElement('canvas')
+      canvas.width = canvasWidth || image.width
+      canvas.height = canvasHeight || image.height
+      const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
+      ctx?.clearRect(0, 0, canvas.width, canvas.height)
+      if (drawWithImageSize) {
+        ctx.drawImage(image, 0, 0, image.width, image.height)
+      } else {
+        ctx.drawImage(image, 0, 0)
+      }
+      const url = canvas.toDataURL('image/png')
+      const a = document.createElement('a')
+      a.href = url
+      a.download = 'image.png'
+      a.click()
+    }
   }
 }
 

+ 1 - 1
src/utils/index.ts

@@ -313,7 +313,7 @@ export const fenToYuan = (price: string | number): string => {
  */
 export const calculateRelativeRate = (value?: number, reference?: number) => {
   // 防止除0
-  if (!reference) return 0
+  if (!reference || reference == 0) return 0
 
   return ((100 * ((value || 0) - reference)) / reference).toFixed(0)
 }

+ 3 - 3
src/views/Login/components/LoginForm.vue

@@ -184,9 +184,9 @@ const loginData = reactive({
   captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
   tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
   loginForm: {
-    tenantName: '芋道源码',
-    username: 'admin',
-    password: 'admin123',
+    tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
+    username: import.meta.env.VITE_APP_DEFAULT_LOGIN_USERNAME || '',
+    password: import.meta.env.VITE_APP_DEFAULT_LOGIN_PASSWORD || '',
     captchaVerification: '',
     rememberMe: true // 默认记录我。如果不需要,可手动修改
   }

+ 104 - 153
src/views/ai/chat/Conversation.vue → src/views/ai/chat/index/components/conversation/ConversationList.vue

@@ -1,11 +1,10 @@
 <!--  AI 对话  -->
 <template>
-  <el-aside width="260px" class="conversation-container" style="height: 100%;">
-
+  <el-aside width="260px" class="conversation-container h-100%">
     <!-- 左顶部:对话 -->
-    <div style="height: 100%;">
+    <div class="h-100%">
       <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
-        <Icon icon="ep:plus" class="mr-5px"/>
+        <Icon icon="ep:plus" class="mr-5px" />
         新建对话
       </el-button>
 
@@ -18,17 +17,20 @@
         @keyup="searchConversation"
       >
         <template #prefix>
-          <Icon icon="ep:search"/>
+          <Icon icon="ep:search" />
         </template>
       </el-input>
 
       <!-- 左中间:对话列表 -->
       <div class="conversation-list">
-
+        <!-- 情况一:加载中 -->
         <el-empty v-if="loading" description="." :v-loading="loading" />
-
+        <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 -->
         <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
-          <div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length">
+          <div
+            class="conversation-item classify-title"
+            v-if="conversationMap[conversationKey].length"
+          >
             <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
           </div>
           <div
@@ -40,76 +42,71 @@
             @mouseout="hoverConversationId = ''"
           >
             <div
-              :class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'"
+              :class="
+                conversation.id === activeConversationId ? 'conversation active' : 'conversation'
+              "
             >
               <div class="title-wrapper">
-                <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg"/>
+                <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
                 <span class="title">{{ conversation.title }}</span>
               </div>
               <div class="button-wrapper" v-show="hoverConversationId === conversation.id">
-                <el-button class="btn" link @click.stop="handlerTop(conversation)" >
+                <el-button class="btn" link @click.stop="handleTop(conversation)">
                   <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
                   <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
                 </el-button>
                 <el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
-                  <el-icon title="编辑" >
-                    <Icon icon="ep:edit"/>
+                  <el-icon title="编辑">
+                    <Icon icon="ep:edit" />
                   </el-icon>
                 </el-button>
                 <el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
-                  <el-icon title="删除对话" >
-                    <Icon icon="ep:delete"/>
+                  <el-icon title="删除对话">
+                    <Icon icon="ep:delete" />
                   </el-icon>
                 </el-button>
               </div>
             </div>
           </div>
         </div>
-        <!--  底部站位  -->
-        <div style="height: 160px; width: 100%;"></div>
+        <!-- 底部占位  -->
+        <div class="h-160px w-100%"></div>
       </div>
-
     </div>
 
     <!-- 左底部:工具栏 -->
-    <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
     <div class="tool-box">
       <div @click="handleRoleRepository">
-        <Icon icon="ep:user"/>
+        <Icon icon="ep:user" />
         <el-text size="small">角色仓库</el-text>
       </div>
       <div @click="handleClearConversation">
-        <Icon icon="ep:delete"/>
+        <Icon icon="ep:delete" />
         <el-text size="small">清空未置顶对话</el-text>
       </div>
     </div>
 
-    <!-- ============= 额外组件 ============= -->
-
     <!-- 角色仓库抽屉 -->
-    <el-drawer v-model="drawer" title="角色仓库" size="754px">
-      <Role/>
+    <el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px">
+      <RoleRepository />
     </el-drawer>
-
   </el-aside>
 </template>
 
 <script setup lang="ts">
-import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
-import {ref} from "vue";
-import Role from "@/views/ai/chat/role/index.vue";
-import {Bottom, Top} from "@element-plus/icons-vue";
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import RoleRepository from '../role/RoleRepository.vue'
+import { Bottom, Top } from '@element-plus/icons-vue'
 import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
 
 const message = useMessage() // 消息弹窗
 
 // 定义属性
 const searchName = ref<string>('') // 对话搜索
-const activeConversationId = ref<string | null>(null) // 选中的对话,默认为 null
-const hoverConversationId = ref<string | null>(null) // 悬浮上去的对话
-const conversationList = ref([] as ChatConversationVO[])  // 对话列表
-const conversationMap = ref<any>({})  // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
-const drawer = ref<boolean>(false) // 角色仓库抽屉 TODO @fan:roleDrawer 会不会好点哈
+const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null
+const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话
+const conversationList = ref([] as ChatConversationVO[]) // 对话列表
+const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
 const loading = ref<boolean>(false) // 加载中
 const loadingTime = ref<any>() // 加载中定时器
 
@@ -129,75 +126,58 @@ const emits = defineEmits([
   'onConversationDelete'
 ])
 
-/**
- * 对话 - 搜索
- */
+/** 搜索对话 */
 const searchConversation = async (e) => {
   // 恢复数据
   if (!searchName.value.trim().length) {
-    conversationMap.value = await conversationTimeGroup(conversationList.value)
+    conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
   } else {
     // 过滤
-    const filterValues = conversationList.value.filter(item => {
+    const filterValues = conversationList.value.filter((item) => {
       return item.title.includes(searchName.value.trim())
     })
-    conversationMap.value = await conversationTimeGroup(filterValues)
+    conversationMap.value = await getConversationGroupByCreateTime(filterValues)
   }
 }
 
-/**
- * 对话 - 点击
- */
-const handleConversationClick = async (id: string) => {
+/** 点击对话 */
+const handleConversationClick = async (id: number) => {
   // 过滤出选中的对话
-  const filterConversation = conversationList.value.filter(item => {
+  const filterConversation = conversationList.value.filter((item) => {
     return item.id === id
   })
   // 回调 onConversationClick
-  // TODO @fan: 这里 idea 会报黄色警告,有办法解下么?
-  const res = emits('onConversationClick', filterConversation[0])
+  // noinspection JSVoidFunctionReturnValueUsed
+  const success = emits('onConversationClick', filterConversation[0])
   // 切换对话
-  if (res) {
+  if (success) {
     activeConversationId.value = id
   }
 }
 
-/**
- * 对话 - 获取列表
- */
+/** 获取对话列表 */
 const getChatConversationList = async () => {
   try {
-    // 0. 加载中
+    // 加载中
     loadingTime.value = setTimeout(() => {
       loading.value = true
     }, 50)
-    // 1. 获取 对话数据
-    const res = await ChatConversationApi.getChatConversationMyList()
-    // 2. 排序
-    res.sort((a, b) => {
+
+    // 1.1 获取 对话数据
+    conversationList.value = await ChatConversationApi.getChatConversationMyList()
+    // 1.2 排序
+    conversationList.value.sort((a, b) => {
       return b.createTime - a.createTime
     })
-    conversationList.value = res
-    // 3. 默认选中
-    if (!activeId?.value) {
-      // await handleConversationClick(res[0].id)
-    } else {
-      // tip: 删除的刚好是选中的,那么需要重新挑选一个来进行选中
-      // const filterConversationList = conversationList.value.filter(item => {
-      //   return item.id === activeId.value
-      // })
-      // if (filterConversationList.length <= 0) {
-      //   await handleConversationClick(res[0].id)
-      // }
-    }
-    // 4. 没有任何对话情况
+    // 1.3 没有任何对话情况
     if (conversationList.value.length === 0) {
       activeConversationId.value = null
       conversationMap.value = {}
       return
     }
-    // 5. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30天前)
-    conversationMap.value = await conversationTimeGroup(conversationList.value)
+
+    // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
+    conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
   } finally {
     // 清理定时器
     if (loadingTime.value) {
@@ -208,31 +188,33 @@ const getChatConversationList = async () => {
   }
 }
 
-const conversationTimeGroup = async (list: ChatConversationVO[]) => {
+/** 按照 creteTime 创建时间,进行分组 */
+const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => {
   // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
+  // noinspection NonAsciiCharacters
   const groupMap = {
-    '置顶': [],
-    '今天': [],
-    '一天前': [],
-    '三天前': [],
-    '七天前': [],
-    '三十天前': []
+    置顶: [],
+    今天: [],
+    一天前: [],
+    三天前: [],
+    七天前: [],
+    三十天前: []
   }
   // 当前时间的时间戳
-  const now = Date.now();
+  const now = Date.now()
   // 定义时间间隔常量(单位:毫秒)
-  const oneDay = 24 * 60 * 60 * 1000;
-  const threeDays = 3 * oneDay;
-  const sevenDays = 7 * oneDay;
-  const thirtyDays = 30 * oneDay;
-  for (const conversation: ChatConversationVO of list) {
+  const oneDay = 24 * 60 * 60 * 1000
+  const threeDays = 3 * oneDay
+  const sevenDays = 7 * oneDay
+  const thirtyDays = 30 * oneDay
+  for (const conversation of list) {
     // 置顶
     if (conversation.pinned) {
       groupMap['置顶'].push(conversation)
       continue
     }
     // 计算时间差(单位:毫秒)
-    const diff = now - conversation.updateTime;
+    const diff = now - conversation.createTime
     // 根据时间间隔判断
     if (diff < oneDay) {
       groupMap['今天'].push(conversation)
@@ -246,13 +228,10 @@ const conversationTimeGroup = async (list: ChatConversationVO[]) => {
       groupMap['三十天前'].push(conversation)
     }
   }
-  console.log('----groupMap', groupMap)
   return groupMap
 }
 
-/**
- * 对话 - 新建
- */
+/** 新建对话 */
 const createConversation = async () => {
   // 1. 新建对话
   const conversationId = await ChatConversationApi.createChatConversationMy(
@@ -266,12 +245,10 @@ const createConversation = async () => {
   emits('onConversationCreate')
 }
 
-/**
- * 对话 - 更新标题
- */
+/** 修改对话的标题 */
 const updateConversationTitle = async (conversation: ChatConversationVO) => {
   // 1. 二次确认
-  const {value} = await ElMessageBox.prompt('修改标题', {
+  const { value } = await ElMessageBox.prompt('修改标题', {
     inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
     inputErrorMessage: '标题不能为空',
     inputValue: conversation.title
@@ -285,7 +262,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => {
   // 3. 刷新列表
   await getChatConversationList()
   // 4. 过滤当前切换的
-  const filterConversationList = conversationList.value.filter(item => {
+  const filterConversationList = conversationList.value.filter((item) => {
     return item.id === conversation.id
   })
   if (filterConversationList.length > 0) {
@@ -296,9 +273,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => {
   }
 }
 
-/**
- * 删除聊天对话
- */
+/** 删除聊天对话 */
 const deleteChatConversation = async (conversation: ChatConversationVO) => {
   try {
     // 删除的二次确认
@@ -310,15 +285,29 @@ const deleteChatConversation = async (conversation: ChatConversationVO) => {
     await getChatConversationList()
     // 回调
     emits('onConversationDelete', conversation)
-  } catch {
-  }
+  } catch {}
+}
+
+/** 清空对话 */
+const handleClearConversation = async () => {
+  try {
+    await message.confirm('确认后对话会全部清空,置顶的对话除外。')
+    await ChatConversationApi.deleteChatConversationMyByUnpinned()
+    ElMessage({
+      message: '操作成功!',
+      type: 'success'
+    })
+    // 清空 对话 和 对话内容
+    activeConversationId.value = null
+    // 获取 对话列表
+    await getChatConversationList()
+    // 回调 方法
+    emits('onConversationClear')
+  } catch {}
 }
 
-/**
- * 对话置顶
- */
-// TODO @fan:应该是 handleXXX,handler 是名词哈
-const handlerTop = async (conversation: ChatConversationVO) => {
+/** 对话置顶 */
+const handleTop = async (conversation: ChatConversationVO) => {
   // 更新对话置顶
   conversation.pinned = !conversation.pinned
   await ChatConversationApi.updateChatConversationMy(conversation)
@@ -326,64 +315,29 @@ const handlerTop = async (conversation: ChatConversationVO) => {
   await getChatConversationList()
 }
 
-// TODO @fan:类似 ============ 分块的,最后后面也有 ============ 哈
-// ============ 角色仓库
+// ============ 角色仓库 ============
 
-/**
- * 角色仓库抽屉
- */
+/** 角色仓库抽屉 */
+const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开
 const handleRoleRepository = async () => {
-  drawer.value = !drawer.value
+  roleRepositoryOpen.value = !roleRepositoryOpen.value
 }
 
-// ============= 清空对话
-
-/**
- * 清空对话
- */
-const handleClearConversation = async () => {
-  // TODO @fan:可以使用 await message.confirm( 简化,然后使用 await 改成同步的逻辑,会更简洁
-  ElMessageBox.confirm(
-    '确认后对话会全部清空,置顶的对话除外。',
-    '确认提示',
-    {
-      confirmButtonText: '确认',
-      cancelButtonText: '取消',
-      type: 'warning',
-    })
-    .then(async () => {
-      await ChatConversationApi.deleteMyAllExceptPinned()
-      ElMessage({
-        message: '操作成功!',
-        type: 'success'
-      })
-      // 清空 对话 和 对话内容
-      activeConversationId.value = null
-      // 获取 对话列表
-      await getChatConversationList()
-      // 回调 方法
-      emits('onConversationClear')
-    })
-    .catch(() => {
-    })
-}
-
-// ============ 组件 onMounted
-
+/** 监听选中的对话 */
 const { activeId } = toRefs(props)
 watch(activeId, async (newValue, oldValue) => {
-  // 更新选中
   activeConversationId.value = newValue as string
 })
 
 // 定义 public 方法
-defineExpose({createConversation})
+defineExpose({ createConversation })
 
+/** 初始化 */
 onMounted(async () => {
   // 获取 对话列表
   await getChatConversationList()
   // 默认选中
-  if (props.activeId != null) {
+  if (props.activeId) {
     activeConversationId.value = props.activeId
   } else {
     // 首次默认选中第一个
@@ -394,18 +348,15 @@ onMounted(async () => {
     }
   }
 })
-
 </script>
 
 <style scoped lang="scss">
-
 .conversation-container {
   position: relative;
   display: flex;
   flex-direction: column;
   justify-content: space-between;
-  padding: 0 10px;
-  padding-top: 10px;
+  padding: 10px 10px 0;
   overflow: hidden;
 
   .btn-new-conversation {

+ 7 - 3
src/views/ai/chat/components/ChatConversationUpdateForm.vue → src/views/ai/chat/index/components/conversation/ConversationUpdateForm.vue

@@ -8,7 +8,12 @@
       v-loading="formLoading"
     >
       <el-form-item label="角色设定" prop="systemMessage">
-        <el-input type="textarea" v-model="formData.systemMessage" rows="4" placeholder="请输入角色设定" />
+        <el-input
+          type="textarea"
+          v-model="formData.systemMessage"
+          rows="4"
+          placeholder="请输入角色设定"
+        />
       </el-form-item>
       <el-form-item label="模型" prop="modelId">
         <el-select v-model="formData.modelId" placeholder="请选择模型">
@@ -57,10 +62,9 @@ import { CommonStatusEnum } from '@/utils/constants'
 import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
 import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
 
-/** AI 聊天角色 表单 */
+/** AI 聊天对话的更新表单 */
 defineOptions({ name: 'ChatConversationUpdateForm' })
 
-const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 
 const dialogVisible = ref(false) // 弹窗的是否展示

+ 52 - 92
src/views/ai/chat/Message.vue → src/views/ai/chat/index/components/message/MessageList.vue

@@ -1,7 +1,7 @@
 <template>
-  <div ref="messageContainer" style="height: 100%; overflow-y: auto; position: relative">
+  <div ref="messageContainer" class="h-100% overflow-y-auto relative">
     <div class="chat-list" v-for="(item, index) in list" :key="index">
-      <!--  靠左 message  -->
+      <!-- 靠左 message:system、assistant 类型 -->
       <div class="left-message message-item" v-if="item.type !== 'user'">
         <div class="avatar">
           <el-avatar :src="roleAvatar" />
@@ -14,16 +14,16 @@
             <MarkdownView class="left-text" :content="item.content" />
           </div>
           <div class="left-btns">
-            <el-button class="btn-cus" link @click="noCopy(item.content)">
+            <el-button class="btn-cus" link @click="copyContent(item.content)">
               <img class="btn-image" src="@/assets/ai/copy.svg" />
             </el-button>
-            <el-button class="btn-cus" link @click="onDelete(item.id)">
-              <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px" />
+            <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)">
+              <img class="btn-image h-17px" src="@/assets/ai/delete.svg" />
             </el-button>
           </div>
         </div>
       </div>
-      <!--  靠右 message  -->
+      <!-- 靠右 message:user 类型 -->
       <div class="right-message message-item" v-if="item.type === 'user'">
         <div class="avatar">
           <el-avatar :src="userAvatar" />
@@ -36,15 +36,11 @@
             <div class="right-text">{{ item.content }}</div>
           </div>
           <div class="right-btns">
-            <el-button class="btn-cus" link @click="noCopy(item.content)">
+            <el-button class="btn-cus" link @click="copyContent(item.content)">
               <img class="btn-image" src="@/assets/ai/copy.svg" />
             </el-button>
             <el-button class="btn-cus" link @click="onDelete(item.id)">
-              <img
-                class="btn-image"
-                src="@/assets/ai/delete.svg"
-                style="height: 17px; margin-right: 12px"
-              />
+              <img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" />
             </el-button>
             <el-button class="btn-cus" link @click="onRefresh(item)">
               <el-icon size="17"><RefreshRight /></el-icon>
@@ -63,23 +59,25 @@
   </div>
 </template>
 <script setup lang="ts">
+import { PropType } from 'vue'
 import { formatDate } from '@/utils/formatTime'
 import MarkdownView from '@/components/MarkdownView/index.vue'
-import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
 import { useClipboard } from '@vueuse/core'
-import { PropType } from 'vue'
 import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
+import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
 import { ChatConversationVO } from '@/api/ai/chat/conversation'
-import {useUserStore} from '@/store/modules/user';
+import { useUserStore } from '@/store/modules/user'
 import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
 import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
 
+const message = useMessage() // 消息弹窗
 const { copy } = useClipboard() // 初始化 copy 到粘贴板
-// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
+const userStore = useUserStore()
+
+// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
 const messageContainer: any = ref(null)
 const isScrolling = ref(false) //用于判断用户是否在滚动
 
-const userStore = useUserStore()
 const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
 const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
 
@@ -95,17 +93,20 @@ const props = defineProps({
   }
 })
 
+const { list } = toRefs(props) // 消息列表
+
+const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits
+
 // ============ 处理对话滚动 ==============
 
+/** 滚动到底部 */
 const scrollToBottom = async (isIgnore?: boolean) => {
-  await nextTick(() => {
-    // TODO @fan:中文写作习惯,中英文之间要有空格;另外,nextick 哈,idea 如果有绿色波兰线,可以关注下
-    //注意要使用nexttick以免获取不到dom
-    if (isIgnore || !isScrolling.value) {
-      messageContainer.value.scrollTop =
-        messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
-    }
-  })
+  // 注意要使用 nextTick 以免获取不到 dom
+  await nextTick()
+  if (isIgnore || !isScrolling.value) {
+    messageContainer.value.scrollTop =
+      messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
+  }
 }
 
 function handleScroll() {
@@ -122,75 +123,48 @@ function handleScroll() {
   }
 }
 
-/**
- * 复制
- */
-const noCopy = async (content) => {
-  copy(content)
-  ElMessage({
-    message: '复制成功!',
-    type: 'success'
-  })
+/** 回到底部 */
+const 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.delete(id)
-  ElMessage({
-    message: '删除成功!',
-    type: 'success'
-  })
+  await ChatMessageApi.deleteChatMessage(id)
+  message.success('删除成功!')
   // 回调
   emits('onDeleteSuccess')
 }
 
-/**
- * 刷新
- */
+/** 刷新 */
 const onRefresh = async (message: ChatMessageVO) => {
   emits('onRefresh', message)
 }
 
-/**
- * 编辑
- */
+/** 编辑 */
 const onEdit = async (message: ChatMessageVO) => {
   emits('onEdit', message)
 }
 
-/**
- * 回到底部
- */
-const handleGoBottom = async () => {
-  const scrollContainer = messageContainer.value
-  scrollContainer.scrollTop = scrollContainer.scrollHeight
-}
-
-/**
- * 回到顶部
- */
-const handlerGoTop = async () => {
-  const scrollContainer = messageContainer.value
-  scrollContainer.scrollTop = 0
-}
-
-// 监听 list
-// TODO @fan:这个木有,是不是删除啦
-const { list, conversationId } = toRefs(props)
-watch(list, async (newValue, oldValue) => {
-  console.log('watch list', list)
-})
-
-// 提供方法给 parent 调用
-defineExpose({ scrollToBottom, handlerGoTop })
-
-// 定义 emits
-const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit'])
-
-// onMounted
+/** 初始化 */
 onMounted(async () => {
   messageContainer.value.addEventListener('scroll', handleScroll)
 })
@@ -199,15 +173,7 @@ onMounted(async () => {
 <style scoped lang="scss">
 .message-container {
   position: relative;
-  //top: 0;
-  //bottom: 0;
-  //left: 0;
-  //right: 0;
-  //width: 100%;
-  //height: 100%;
   overflow-y: scroll;
-  //padding: 0 15px;
-  //z-index: -1;
 }
 
 // 中间
@@ -231,11 +197,6 @@ onMounted(async () => {
     justify-content: flex-start;
   }
 
-  .avatar {
-    //height: 170px;
-    //width: 170px;
-  }
-
   .message {
     display: flex;
     flex-direction: column;
@@ -272,7 +233,6 @@ onMounted(async () => {
         color: #fff;
         display: inline;
         background-color: #267fff;
-        color: #fff;
         box-shadow: 0 0 0 1px #267fff;
         border-radius: 10px;
         padding: 10px;

+ 15 - 11
src/views/ai/chat/ChatEmpty.vue → src/views/ai/chat/index/components/message/MessageListEmpty.vue

@@ -1,31 +1,35 @@
+<!-- 消息列表为空时,展示 prompt 列表 -->
 <template>
   <div class="chat-empty">
-
-    <!--  title  -->
+    <!-- title -->
     <div class="center-container">
-      <div class="title">芋 AI</div>
+      <div class="title">芋 AI</div>
       <div class="role-list">
-        <div class="role-item" v-for="prompt in promptList" :key="prompt.prompt" @click="handlerPromptClick(prompt)">
-          {{prompt.prompt}}
+        <div
+          class="role-item"
+          v-for="prompt in promptList"
+          :key="prompt.prompt"
+          @click="handlerPromptClick(prompt)"
+        >
+          {{ prompt.prompt }}
         </div>
       </div>
     </div>
   </div>
 </template>
 <script setup lang="ts">
-
-const promptList = ref<any[]>() // 角色列表
-promptList.value = [
+const promptList = [
   {
-    "prompt": "今天气怎么样?",
+    prompt: '今天气怎么样?'
   },
   {
-    "prompt": "写一首好听的诗歌?",
+    prompt: '写一首好听的诗歌?'
   }
-]
+] // prompt 列表
 
 const emits = defineEmits(['onPrompt'])
 
+/** 选中 prompt 点击 */
 const handlerPromptClick = async ({ prompt }) => {
   emits('onPrompt', prompt)
 }

+ 0 - 0
src/views/ai/chat/MessageLoading.vue → src/views/ai/chat/index/components/message/MessageLoading.vue


+ 9 - 13
src/views/ai/chat/MessageNewChat.vue → src/views/ai/chat/index/components/message/MessageNewConversation.vue

@@ -1,25 +1,21 @@
-<!-- message 新增对话 -->
+<!-- 无聊天对话时,在 message 区域,可以新增对话 -->
 <template>
-  <div class="new-chat" >
+  <div class="new-chat">
     <div class="box-center">
       <div class="tip">点击下方按钮,开始你的对话吧</div>
-      <div class="btns"><el-button type="primary" round @click="handlerNewChat">新建对话</el-button></div>
+      <div class="btns">
+        <el-button type="primary" round @click="handlerNewChat">新建对话</el-button>
+      </div>
     </div>
   </div>
 </template>
-
 <script setup lang="ts">
+const emits = defineEmits(['onNewConversation'])
 
-// 定义钩子
-const emits = defineEmits(['onNewChat'])
-
-/**
- * 新建 chat
- */
-const handlerNewChat = async () => {
-  await emits('onNewChat')
+/** 新建 conversation 聊天对话 */
+const handlerNewChat = () => {
+  emits('onNewConversation')
 }
-
 </script>
 <style scoped lang="scss">
 .new-chat {

+ 13 - 7
src/views/ai/chat/role/RoleCategoryList.vue → src/views/ai/chat/index/components/role/RoleCategoryList.vue

@@ -1,13 +1,20 @@
 <template>
   <div class="category-list">
-    <div class="category" v-for="(category) in categoryList" :key="category">
-      <el-button plain round size="small" v-if="category !== active" @click="handleCategoryClick(category)">{{ category }}</el-button>
-      <el-button plain round size="small" v-else type="primary" @click="handleCategoryClick(category)">{{ category }}</el-button>
+    <div class="category" v-for="category in categoryList" :key="category">
+      <el-button
+        plain
+        round
+        size="small"
+        :type="category === active ? 'primary' : ''"
+        @click="handleCategoryClick(category)"
+      >
+        {{ category }}
+      </el-button>
     </div>
   </div>
 </template>
 <script setup lang="ts">
-import {PropType} from "vue";
+import { PropType } from 'vue'
 
 // 定义属性
 defineProps({
@@ -25,11 +32,10 @@ defineProps({
 // 定义回调
 const emits = defineEmits(['onCategoryClick'])
 
-// 处理分类点击事件
-const handleCategoryClick = async (category) => {
+/** 处理分类点击事件 */
+const handleCategoryClick = async (category: string) => {
   emits('onCategoryClick', category)
 }
-
 </script>
 <style scoped lang="scss">
 .category-list {

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


+ 18 - 32
src/views/ai/chat/role/RoleList.vue → src/views/ai/chat/index/components/role/RoleList.vue

@@ -1,32 +1,30 @@
 <template>
-  <div class="card-list" ref="tabsRef"  @scroll="handleTabsScroll">
+  <div class="card-list" ref="tabsRef" @scroll="handleTabsScroll">
     <div class="card-item" v-for="role in roleList" :key="role.id">
       <el-card class="card" body-class="card-body">
-        <!--  更多 -->
+        <!-- 更多操作 -->
         <div class="more-container" v-if="showMore">
           <el-dropdown @command="handleMoreClick">
-          <span class="el-dropdown-link">
-             <el-button type="text" >
+            <span class="el-dropdown-link">
+              <el-button type="text">
                 <el-icon><More /></el-icon>
               </el-button>
-          </span>
-            <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
+            </span>
             <template #dropdown>
               <el-dropdown-menu>
-                <el-dropdown-item :command="['edit', role]" >
-                  <el-icon><EditPen /></el-icon>编辑
+                <el-dropdown-item :command="['edit', role]">
+                  <Icon icon="ep:edit" color="#787878" />编辑
                 </el-dropdown-item>
-                <el-dropdown-item :command="['delete', role]"  style="color: red;" >
-                  <el-icon><Delete /></el-icon>
-                  <span>删除</span>
+                <el-dropdown-item :command="['delete', role]" style="color: red">
+                  <Icon icon="ep:delete" color="red" />删除
                 </el-dropdown-item>
               </el-dropdown-menu>
             </template>
           </el-dropdown>
         </div>
-        <!--  头像 -->
+        <!-- 角色信息 -->
         <div>
-          <img class="avatar" :src="role.avatar"/>
+          <img class="avatar" :src="role.avatar" />
         </div>
         <div class="right-container">
           <div class="content-container">
@@ -44,8 +42,8 @@
 
 <script setup lang="ts">
 import {ChatRoleVO} from '@/api/ai/model/chatRole'
-import {PropType, ref} from "vue";
-import {Delete, EditPen, More} from "@element-plus/icons-vue";
+import {PropType, ref} from 'vue'
+import {More} from '@element-plus/icons-vue'
 
 const tabsRef = ref<any>() // tabs ref
 
@@ -65,10 +63,11 @@ const props = defineProps({
     default: false
   }
 })
+
 // 定义钩子
 const emits = defineEmits(['onDelete', 'onEdit', 'onUse', 'onPage'])
 
-// more 点击
+/** 操作:编辑、删除 */
 const handleMoreClick = async (data) => {
   const type = data[0]
   const role = data[1]
@@ -79,28 +78,20 @@ const handleMoreClick = async (data) => {
   }
 }
 
-// 使用
+/** 选中 */
 const handleUseClick = (role) => {
   emits('onUse', role)
 }
 
+/** 滚动 */
 const handleTabsScroll = async () => {
   if (tabsRef.value) {
-    const { scrollTop, scrollHeight, clientHeight } = tabsRef.value;
-    console.log('scrollTop', scrollTop)
+    const { scrollTop, scrollHeight, clientHeight } = tabsRef.value
     if (scrollTop + clientHeight >= scrollHeight - 20 && !props.loading) {
-      console.log('分页')
-      // page.value++;
-      // fetchData(page.value);
       await emits('onPage')
     }
   }
 }
-
-onMounted(() => {
-  console.log('props', props.roleList)
-})
-
 </script>
 
 <style lang="scss">
@@ -114,11 +105,9 @@ onMounted(() => {
   flex-direction: row;
   justify-content: flex-start;
   position: relative;
-
 }
 </style>
 <style scoped lang="scss">
-
 // 卡片列表
 .card-list {
   display: flex;
@@ -180,9 +169,6 @@ onMounted(() => {
         margin-top: 2px;
       }
     }
-
   }
-
 }
-
 </style>

+ 43 - 50
src/views/ai/chat/role/index.vue → src/views/ai/chat/index/components/role/RoleRepository.vue

@@ -2,8 +2,8 @@
 <template>
   <el-container class="role-container">
     <ChatRoleForm ref="formRef" @success="handlerAddRoleSuccess" />
-    <!--  header  -->
-    <Header title="角色仓库" style="position: relative" />
+    <!-- header  -->
+    <RoleHeader title="角色仓库" class="relative" />
     <!--  main  -->
     <el-main class="role-main">
       <div class="search-container">
@@ -18,20 +18,17 @@
           @change="getActiveTabsRole"
         />
         <el-button
-          v-if="activeRole == 'my-role'"
+          v-if="activeTab == 'my-role'"
           type="primary"
           @click="handlerAddRole"
-          style="margin-left: 20px"
+          class="ml-20px"
         >
-          <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
-          <el-icon>
-            <User />
-          </el-icon>
+          <Icon icon="ep:user" style="margin-right: 5px;" />
           添加角色
         </el-button>
       </div>
       <!-- tabs -->
-      <el-tabs v-model="activeRole" class="tabs" @tab-click="handleTabsClick">
+      <el-tabs v-model="activeTab" class="tabs" @tab-click="handleTabsClick">
         <el-tab-pane class="role-pane" label="我的角色" name="my-role">
           <RoleList
             :loading="loading"
@@ -41,7 +38,7 @@
             @on-edit="handlerCardEdit"
             @on-use="handlerCardUse"
             @on-page="handlerCardPage('my')"
-            style="margin-top: 20px"
+            class="mt-20px"
           />
         </el-tab-pane>
         <el-tab-pane label="公共角色" name="public-role">
@@ -57,7 +54,7 @@
             @on-edit="handlerCardEdit"
             @on-use="handlerCardUse"
             @on-page="handlerCardPage('public')"
-            style="margin-top: 20px"
+            class="mt-20px"
             loading
           />
         </el-tab-pane>
@@ -67,28 +64,31 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
-import Header from '@/views/ai/chat/components/Header.vue'
+import {ref} from 'vue'
+import RoleHeader from './RoleHeader.vue'
 import RoleList from './RoleList.vue'
 import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
 import RoleCategoryList from './RoleCategoryList.vue'
-import { ChatRoleApi, ChatRolePageReqVO, ChatRoleVO } from '@/api/ai/model/chatRole'
-import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
-import { TabsPaneContext } from 'element-plus'
-import { Search, User } from '@element-plus/icons-vue'
+import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole'
+import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
+import {Search} from '@element-plus/icons-vue'
+import {TabsPaneContext} from 'element-plus'
 
 const router = useRouter() // 路由对象
 
 // 属性定义
 const loading = ref<boolean>(false) // 加载中
-const activeRole = ref<string>('my-role') // 选中的角色 TODO @fan:是不是叫 activeTab 会更明确一点哈。选中的角色,会以为是某个角色
+const activeTab = ref<string>('my-role') // 选中的角色 Tab
 const search = ref<string>('') // 加载中
-// TODO @fan:要不 myPage、pubPage,搞成类似 const queryParams = reactive({ ,分别搞成两个大的参数哈?
-const myPageNo = ref<number>(1) // my 分页下标
-const myPageSize = ref<number>(50) // my 分页大小
+const myRoleParams = reactive({
+  pageNo: 1,
+  pageSize: 50
+})
 const myRoleList = ref<ChatRoleVO[]>([]) // my 分页大小
-const publicPageNo = ref<number>(1) // public 分页下标
-const publicPageSize = ref<number>(50) // public 分页大小
+const publicRoleParams = reactive({
+  pageNo: 1,
+  pageSize: 50
+})
 const publicRoleList = ref<ChatRoleVO[]>([]) // public 分页大小
 const activeCategory = ref<string>('全部') // 选择中的分类
 const categoryList = ref<string[]>([]) // 角色分类类别
@@ -96,7 +96,7 @@ const categoryList = ref<string[]>([]) // 角色分类类别
 /** tabs 点击 */
 const handleTabsClick = async (tab: TabsPaneContext) => {
   // 设置切换状态
-  activeRole.value = tab.paneName + ''
+  activeTab.value = tab.paneName + ''
   // 切换的时候重新加载数据
   await getActiveTabsRole()
 }
@@ -104,12 +104,11 @@ const handleTabsClick = async (tab: TabsPaneContext) => {
 /** 获取 my role 我的角色 */
 const getMyRole = async (append?: boolean) => {
   const params: ChatRolePageReqVO = {
-    pageNo: myPageNo.value,
-    pageSize: myPageSize.value,
+    ...myRoleParams,
     name: search.value,
     publicStatus: false
   }
-  const { total, list } = await ChatRoleApi.getMyPage(params)
+  const { list } = await ChatRoleApi.getMyPage(params)
   if (append) {
     myRoleList.value.push.apply(myRoleList.value, list)
   } else {
@@ -120,8 +119,7 @@ const getMyRole = async (append?: boolean) => {
 /** 获取 public role 公共角色 */
 const getPublicRole = async (append?: boolean) => {
   const params: ChatRolePageReqVO = {
-    pageNo: publicPageNo.value,
-    pageSize: publicPageSize.value,
+    ...publicRoleParams,
     category: activeCategory.value === '全部' ? '' : activeCategory.value,
     name: search.value,
     publicStatus: true
@@ -136,20 +134,18 @@ const getPublicRole = async (append?: boolean) => {
 
 /** 获取选中的 tabs 角色 */
 const getActiveTabsRole = async () => {
-  if (activeRole.value === 'my-role') {
-    myPageNo.value = 1
+  if (activeTab.value === 'my-role') {
+    myRoleParams.pageNo = 1
     await getMyRole()
   } else {
-    publicPageNo.value = 1
+    publicRoleParams.pageNo = 1
     await getPublicRole()
   }
 }
 
 /** 获取角色分类列表 */
 const getRoleCategoryList = async () => {
-  const res = await ChatRoleApi.getCategoryList()
-  const defaultRole = ['全部']
-  categoryList.value = [...defaultRole, ...res]
+  categoryList.value = ['全部', ...(await ChatRoleApi.getCategoryList())]
 }
 
 /** 处理分类点击 */
@@ -165,6 +161,10 @@ const formRef = ref()
 const handlerAddRole = async () => {
   formRef.value.open('my-create', null, '添加角色')
 }
+/** 编辑角色 */
+const handlerCardEdit = async (role) => {
+  formRef.value.open('my-update', role.id, '编辑角色')
+}
 
 /** 添加角色成功 */
 const handlerAddRoleSuccess = async (e) => {
@@ -172,28 +172,22 @@ const handlerAddRoleSuccess = async (e) => {
   await getActiveTabsRole()
 }
 
-// card 删除
+/** 删除角色 */
 const handlerCardDelete = async (role) => {
   await ChatRoleApi.deleteMy(role.id)
   // 刷新数据
   await getActiveTabsRole()
 }
 
-// card 编辑
-const handlerCardEdit = async (role) => {
-  formRef.value.open('my-update', role.id, '编辑角色')
-}
-
-/** card 分页:获取下一页 */
+/** 角色分页:获取下一页 */
 const handlerCardPage = async (type) => {
-  console.log('handlerCardPage', type)
   try {
     loading.value = true
     if (type === 'public') {
-      publicPageNo.value++
+      publicRoleParams.pageNo++
       await getPublicRole(true)
     } else {
-      myPageNo.value++
+      myRoleParams.pageNo++
       await getMyRole(true)
     }
   } finally {
@@ -208,10 +202,10 @@ const handlerCardUse = async (role) => {
     roleId: role.id
   } as unknown as ChatConversationVO
   const conversationId = await ChatConversationApi.createChatConversationMy(data)
+
   // 2. 跳转页面
-  // TODO @fan:最好用 name,后续可能会改~~~
   await router.push({
-    path: `/ai/chat`,
+    name: 'AiChat',
     query: {
       conversationId: conversationId
     }
@@ -225,15 +219,14 @@ onMounted(async () => {
   // 获取 role 数据
   await getActiveTabsRole()
 })
-// TODO @fan:css 是不是可以融合到 scss 里面呀?
 </script>
-<style lang="css">
+<!-- 覆盖 element ui css -->
+<style lang="scss">
 .el-tabs__content {
   position: relative;
   height: 100%;
   overflow: hidden;
 }
-
 .el-tabs__nav-scroll {
   margin: 10px 20px;
 }

+ 355 - 405
src/views/ai/chat/index.vue → src/views/ai/chat/index/index.vue

@@ -1,33 +1,35 @@
 <template>
   <el-container class="ai-layout">
     <!-- 左侧:对话列表 -->
-    <Conversation
+    <ConversationList
       :active-id="activeConversationId"
-      ref="conversationRef"
-      @onConversationCreate="handleConversationCreate"
-      @onConversationClick="handleConversationClick"
-      @onConversationClear="handlerConversationClear"
-      @onConversationDelete="handlerConversationDelete"
+      ref="conversationListRef"
+      @on-conversation-create="handleConversationCreateSuccess"
+      @on-conversation-click="handleConversationClick"
+      @on-conversation-clear="handleConversationClear"
+      @on-conversation-delete="handlerConversationDelete"
     />
     <!-- 右侧:对话详情 -->
     <el-container class="detail-container">
       <el-header class="header">
         <div class="title">
           {{ activeConversation?.title ? activeConversation?.title : '对话' }}
-          <span v-if="list.length">({{ list.length }})</span>
+          <span v-if="activeMessageList.length">({{ activeMessageList.length }})</span>
         </div>
         <div class="btns" v-if="activeConversation">
           <el-button type="primary" bg plain size="small" @click="openChatConversationUpdateForm">
             <span v-html="activeConversation?.modelName"></span>
-            <Icon icon="ep:setting" style="margin-left: 10px" />
+            <Icon icon="ep:setting" class="ml-10px" />
           </el-button>
           <el-button size="small" class="btn" @click="handlerMessageClear">
-            <!-- TODO @fan:style 部分,可以考虑用 unocss 替代 -->
-            <img src="@/assets/ai/clear.svg" style="height: 14px" />
+            <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
+          </el-button>
+          <el-button size="small" class="btn">
+            <Icon icon="ep:download" color="#787878" />
+          </el-button>
+          <el-button size="small" class="btn" @click="handleGoTopMessage">
+            <Icon icon="ep:top" color="#787878" />
           </el-button>
-          <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
-          <el-button size="small" :icon="Download" class="btn" />
-          <el-button size="small" :icon="Top" class="btn" @click="handlerGoTop" />
         </div>
       </el-header>
 
@@ -35,20 +37,27 @@
       <el-main class="main-container">
         <div>
           <div class="message-container">
-            <MessageLoading v-if="listLoading" />
-            <MessageNewChat v-if="!activeConversation" @on-new-chat="handlerNewChat" />
-            <ChatEmpty
-              v-if="!listLoading && messageList.length === 0 && activeConversation"
-              @on-prompt="doSend"
+            <!-- 情况一:消息加载中 -->
+            <MessageLoading v-if="activeMessageListLoading" />
+            <!-- 情况二:无聊天对话时 -->
+            <MessageNewConversation
+              v-if="!activeConversation"
+              @on-new-conversation="handleConversationCreate"
+            />
+            <!-- 情况三:消息列表为空 -->
+            <MessageListEmpty
+              v-if="!activeMessageListLoading && messageList.length === 0 && activeConversation"
+              @on-prompt="doSendMessage"
             />
-            <Message
-              v-if="!listLoading && messageList.length > 0"
+            <!-- 情况四:消息列表不为空 -->
+            <MessageList
+              v-if="!activeMessageListLoading && messageList.length > 0"
               ref="messageRef"
               :conversation="activeConversation"
               :list="messageList"
-              @on-delete-success="handlerMessageDelete"
-              @on-edit="handlerMessageEdit"
-              @on-refresh="handlerMessageRefresh"
+              @on-delete-success="handleMessageDelete"
+              @on-edit="handleMessageEdit"
+              @on-refresh="handleMessageRefresh"
             />
           </div>
         </div>
@@ -60,8 +69,8 @@
           <textarea
             class="prompt-input"
             v-model="prompt"
-            @keydown="onSend"
-            @input="onPromptInput"
+            @keydown="handleSendByKeydown"
+            @input="handlePromptInput"
             @compositionstart="onCompositionstart"
             @compositionend="onCompositionend"
             placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
@@ -69,12 +78,12 @@
           <div class="prompt-btns">
             <div>
               <el-switch v-model="enableContext" />
-              <span style="font-size: 14px; color: #8f8f8f">上下文</span>
+              <span class="ml-5px text-14px text-#8f8f8f">上下文</span>
             </div>
             <el-button
               type="primary"
               size="default"
-              @click="onSendBtn"
+              @click="handleSendByButton"
               :loading="conversationInProgress"
               v-if="conversationInProgress == false"
             >
@@ -93,172 +102,222 @@
       </el-footer>
     </el-container>
 
-    <!--  ========= 额外组件 ==========  -->
     <!-- 更新对话 Form -->
-    <ChatConversationUpdateForm
-      ref="chatConversationUpdateFormRef"
-      @success="handlerTitleSuccess"
+    <ConversationUpdateForm
+      ref="conversationUpdateFormRef"
+      @success="handleConversationUpdateSuccess"
     />
   </el-container>
 </template>
 
 <script setup lang="ts">
-// TODO @fan:是不是把 index.vue 相关的,在这里新建一个 index 目录,然后挪进去哈。因为 /ai/chat 还会有其它功能。例如说,现在的 /ai/chat/manager 管理
-import Conversation from './Conversation.vue'
-import Message from './Message.vue'
-import ChatEmpty from './ChatEmpty.vue'
-import MessageLoading from './MessageLoading.vue'
-import MessageNewChat from './MessageNewChat.vue'
 import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
 import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
-import ChatConversationUpdateForm from '@/views/ai/chat/components/ChatConversationUpdateForm.vue'
-import { Download, Top } from '@element-plus/icons-vue'
+import ConversationList from './components/conversation/ConversationList.vue'
+import ConversationUpdateForm from './components/conversation/ConversationUpdateForm.vue'
+import MessageList from './components/message/MessageList.vue'
+import MessageListEmpty from './components/message/MessageListEmpty.vue'
+import MessageLoading from './components/message/MessageLoading.vue'
+import MessageNewConversation from './components/message/MessageNewConversation.vue'
+
+/** AI 聊天对话 列表 */
+defineOptions({ name: 'AiChat' })
 
 const route = useRoute() // 路由
 const message = useMessage() // 消息弹窗
 
-// ref 属性定义
-const activeConversationId = ref<string | null>(null) // 选中的对话编号
+// 聊天对话
+const conversationListRef = ref()
+const activeConversationId = ref<number | null>(null) // 选中的对话编号
 const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
-const conversationInProgress = ref(false) // 对话进行中
+const conversationInProgress = ref(false) // 对话是否正在进行中。目前只有【发送】消息时,会更新为 true,避免切换对话、删除对话等操作
+
+// 消息列表
+const messageRef = ref()
+const activeMessageList = ref<ChatMessageVO[]>([]) // 选中对话的消息列表
+const activeMessageListLoading = ref<boolean>(false) // activeMessageList 是否正在加载中
+const activeMessageListLoadingTimer = ref<any>() // activeMessageListLoading Timer 定时器。如果加载速度很快,就不进入加载中
+// 消息滚动
+const textSpeed = ref<number>(50) // Typing speed in milliseconds
+const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
+
+// 发送消息输入框
+const isComposing = ref(false) // 判断用户是否在输入
 const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
 const inputTimeout = ref<any>() // 处理输入中回车的定时器
 const prompt = ref<string>() // prompt
 const enableContext = ref<boolean>(true) // 是否开启上下文
+// 接收 Stream 消息
+const receiveMessageFullText = ref('')
+const receiveMessageDisplayedText = ref('')
 
-// TODO @fan:这几个变量,可以注释在补下哈;另外,fullText 可以明确是生成中的消息 Text,这样更容易理解哈;
-const fullText = ref('')
-const displayedText = ref('')
-const textSpeed = ref<number>(50) // Typing speed in milliseconds
-const textRoleRunning = ref<boolean>(false) // Typing speed in milliseconds
+// =========== 【聊天对话】相关 ===========
 
-// chat message 列表
-// TODO @fan:list、listLoading、listLoadingTime 不能体现出来是消息列表,是不是可以变量再优化下
-const list = ref<ChatMessageVO[]>([]) // 列表的数据
-const listLoading = ref<boolean>(false) // 是否加载中
-const listLoadingTime = ref<any>() // time 定时器,如果加载速度很快,就不进入加载中
+/** 获取对话信息 */
+const getConversation = async (id: number | null) => {
+  if (!id) {
+    return
+  }
+  const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
+  if (!conversation) {
+    return
+  }
+  activeConversation.value = conversation
+  activeConversationId.value = conversation.id
+}
 
-// 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
-const messageRef = ref()
-const conversationRef = ref()
-const isComposing = ref(false) // 判断用户是否在输入
+/**
+ * 点击某个对话
+ *
+ * @param conversation 选中的对话
+ * @return 是否切换成功
+ */
+const handleConversationClick = async (conversation: ChatConversationVO) => {
+  // 对话进行中,不允许切换
+  if (conversationInProgress.value) {
+    message.alert('对话中,不允许切换!')
+    return false
+  }
 
-// 默认 role 头像
-const defaultRoleAvatar =
-  'http://test.yudao.iocoder.cn/eaef5f41acb911dd718429a0702dcc3c61160d16e57ba1d543132fab58934f9f.png'
+  // 更新选中的对话 id
+  activeConversationId.value = conversation.id
+  activeConversation.value = conversation
+  // 刷新 message 列表
+  await getMessageList()
+  // 滚动底部
+  scrollToBottom(true)
+  // 清空输入框
+  prompt.value = ''
+  return true
+}
 
-// =========== 自提滚动效果
+/** 删除某个对话*/
+const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
+  // 删除的对话如果是当前选中的,那么就重置
+  if (activeConversationId.value === delConversation.id) {
+    await handleConversationClear()
+  }
+}
+/** 清空选中的对话 */
+const handleConversationClear = async () => {
+  // 对话进行中,不允许切换
+  if (conversationInProgress.value) {
+    message.alert('对话中,不允许切换!')
+    return false
+  }
+  activeConversationId.value = null
+  activeConversation.value = null
+  activeMessageList.value = []
+}
 
-// TODO @fan:这个方法,要不加个方法注释
-const textRoll = async () => {
-  let index = 0
-  try {
-    // 只能执行一次
-    if (textRoleRunning.value) {
-      return
-    }
-    // 设置状态
-    textRoleRunning.value = true
-    displayedText.value = ''
-    const task = async () => {
-      // 调整速度
-      const diff = (fullText.value.length - displayedText.value.length) / 10
-      if (diff > 5) {
-        textSpeed.value = 10
-      } else if (diff > 2) {
-        textSpeed.value = 30
-      } else if (diff > 1.5) {
-        textSpeed.value = 50
-      } else {
-        textSpeed.value = 100
-      }
-      // 对话结束,就按 30 的速度
-      if (!conversationInProgress.value) {
-        textSpeed.value = 10
-      }
+/** 修改聊天对话 */
+const conversationUpdateFormRef = ref()
+const openChatConversationUpdateForm = async () => {
+  conversationUpdateFormRef.value.open(activeConversationId.value)
+}
+const handleConversationUpdateSuccess = async () => {
+  // 对话更新成功,刷新最新信息
+  await getConversation(activeConversationId.value)
+}
 
-      // console.log('index < fullText.value.length', index < fullText.value.length, conversationInProgress.value)
+/** 处理聊天对话的创建成功 */
+const handleConversationCreate = async () => {
+  // 创建对话
+  await conversationListRef.value.createConversation()
+}
+/** 处理聊天对话的创建成功 */
+const handleConversationCreateSuccess = async () => {
+  // 创建新的对话,清空输入框
+  prompt.value = ''
+}
 
-      if (index < fullText.value.length) {
-        displayedText.value += fullText.value[index]
-        index++
+// =========== 【消息列表】相关 ===========
 
-        // 更新 message
-        const lastMessage = list.value[list.value.length - 1]
-        lastMessage.content = displayedText.value
-        // TODO @fan:ist.value?,还是 ist.value.length 哈?
-        list.value[list.value - 1] = lastMessage
-        // 滚动到住下面
-        await scrollToBottom()
-        // 重新设置任务
-        timer = setTimeout(task, textSpeed.value)
-      } else {
-        // 不是对话中可以结束
-        if (!conversationInProgress.value) {
-          textRoleRunning.value = false
-          clearTimeout(timer)
-          console.log('字体滚动退出!')
-        } else {
-          // 重新设置任务
-          timer = setTimeout(task, textSpeed.value)
-        }
-      }
+/** 获取消息 message 列表 */
+const getMessageList = async () => {
+  try {
+    if (activeConversationId.value === null) {
+      return
     }
-    let timer = setTimeout(task, textSpeed.value)
-  } finally {
-  }
-}
+    // Timer 定时器,如果加载速度很快,就不进入加载中
+    activeMessageListLoadingTimer.value = setTimeout(() => {
+      activeMessageListLoading.value = true
+    }, 60)
 
-// ============ 处理对话滚动 ==============
+    // 获取消息列表
+    activeMessageList.value = await ChatMessageApi.getChatMessageListByConversationId(
+      activeConversationId.value
+    )
 
-function scrollToBottom(isIgnore?: boolean) {
-  // isIgnore = isIgnore !== null ? isIgnore : false
-  nextTick(() => {
-    if (messageRef.value) {
-      messageRef.value.scrollToBottom(isIgnore)
+    // 滚动到最下面
+    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 []
+})
 
-// TODO @fan:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
-const onCompositionstart = () => {
-  isComposing.value = true
+/** 处理删除 message 消息 */
+const handleMessageDelete = () => {
+  if (conversationInProgress.value) {
+    message.alert('回答中,不能删除!')
+    return
+  }
+  // 刷新 message 列表
+  getMessageList()
 }
 
-const onCompositionend = () => {
-  // console.log('输入结束...')
-  setTimeout(() => {
-    isComposing.value = false
-  }, 200)
+/** 处理 message 清空 */
+const handlerMessageClear = async () => {
+  if (!activeConversationId.value) {
+    return
+  }
+  try {
+    // 确认提示
+    await message.delConfirm('确认清空对话消息?')
+    // 清空对话
+    await ChatMessageApi.deleteByConversationId(activeConversationId.value)
+    // 刷新 message 列表
+    activeMessageList.value = []
+  } catch {}
 }
 
-const onPromptInput = (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)
+/** 回到 message 列表的顶部 */
+const handleGoTopMessage = () => {
+  messageRef.value.handlerGoTop()
 }
 
-// ============== 对话消息相关 =================
+// =========== 【发送消息】相关 ===========
 
-/**
- * 发送消息
- */
-const onSend = async (event) => {
+/** 处理来自 keydown 的发送消息 */
+const handleSendByKeydown = async (event) => {
   // 判断用户是否在输入
   if (isComposing.value) {
     return
@@ -275,83 +334,106 @@ const onSend = async (event) => {
       event.preventDefault() // 防止默认的换行行为
     } else {
       // 发送消息
-      await doSend(content)
+      await doSendMessage(content)
       event.preventDefault() // 防止默认的提交行为
     }
   }
 }
 
-const onSendBtn = async () => {
-  await doSend(prompt.value?.trim() as string)
+/** 处理来自【发送】按钮的发送消息 */
+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 doSend = async (content: string) => {
-  if (content.length < 2) {
-    // TODO @fan:这个 message.error(`上传文件大小不能超过${props.fileSize}MB!`) 可以替代,这种形式
-    ElMessage({
-      message: '请输入内容!',
-      type: 'error'
-    })
+/** 真正执行【发送】消息操作 */
+const doSendMessage = async (content: string) => {
+  // 校验
+  if (content.length < 1) {
+    message.error('发送失败,原因:内容为空!')
     return
   }
-  // TODO @fan:这个 message.error(`上传文件大小不能超过${props.fileSize}MB!`) 可以替代,这种形式
   if (activeConversationId.value == null) {
-    ElMessage({
-      message: '还没创建对话,不能发送!',
-      type: 'error'
-    })
+    message.error('还没创建对话,不能发送!')
     return
   }
   // 清空输入框
   prompt.value = ''
-  // TODO @fan:idea 这里会报类型错误,是不是可以解决下哈
-  const userMessage = {
+  // 执行发送
+  await doSendMessageStream({
     conversationId: activeConversationId.value,
     content: content
-  } as ChatMessageVO
-  // stream
-  await doSendStream(userMessage)
+  } as ChatMessageVO)
 }
 
-const doSendStream = async (userMessage: ChatMessageVO) => {
-  // 创建AbortController实例,以便中止请求
+/** 真正执行【发送】消息操作 */
+const doSendMessageStream = async (userMessage: ChatMessageVO) => {
+  // 创建 AbortController 实例,以便中止请求
   conversationInAbortController.value = new AbortController()
   // 标记对话进行中
   conversationInProgress.value = true
   // 设置为空
-  fullText.value = ''
+  receiveMessageFullText.value = ''
+
   try {
-    // 先添加两个假数据,等 stream 返回再替换
-    list.value.push({
+    // 1.1 先添加两个假数据,等 stream 返回再替换
+    activeMessageList.value.push({
       id: -1,
       conversationId: activeConversationId.value,
       type: 'user',
       content: userMessage.content,
       createTime: new Date()
     } as ChatMessageVO)
-    list.value.push({
+    activeMessageList.value.push({
       id: -2,
       conversationId: activeConversationId.value,
-      type: 'system',
+      type: 'assistant',
       content: '思考中...',
       createTime: new Date()
     } as ChatMessageVO)
-    // 滚动到最下面
-    // TODO @fan:可以 await nextTick();然后同步调用 scrollToBottom()
-    nextTick(async () => {
-      await scrollToBottom()
-    })
-    // 开始滚动
+    // 1.2 滚动到最下面
+    await nextTick()
+    await scrollToBottom() // 底部
+    // 1.3 开始滚动
     textRoll()
-    // 发送 event stream
-    let isFirstMessage = true // TODO @fan:isFirstChunk 会更精准
-    ChatMessageApi.sendStream(
-      userMessage.conversationId, // TODO 芋艿:这里可能要在优化;
+
+    // 2. 发送 event stream
+    let isFirstChunk = true // 是否是第一个 chunk 消息段
+    await ChatMessageApi.sendChatMessageStream(
+      userMessage.conversationId,
       userMessage.content,
       conversationInAbortController.value,
       enableContext.value,
       async (res) => {
-        console.log('res', res)
         const { code, data, msg } = JSON.parse(res.data)
         if (code !== 0) {
           message.alert(`对话异常! ${msg}`)
@@ -363,42 +445,33 @@ const doSendStream = async (userMessage: ChatMessageVO) => {
           return
         }
         // 首次返回需要添加一个 message 到页面,后面的都是更新
-        if (isFirstMessage) {
-          isFirstMessage = false
-          // 弹出两个 假数据
-          list.value.pop()
-          list.value.pop()
+        if (isFirstChunk) {
+          isFirstChunk = false
+          // 弹出两个假数据
+          activeMessageList.value.pop()
+          activeMessageList.value.pop()
           // 更新返回的数据
-          list.value.push(data.send)
-          list.value.push(data.receive)
+          activeMessageList.value.push(data.send)
+          activeMessageList.value.push(data.receive)
         }
         // debugger
-        fullText.value = fullText.value + data.receive.content
+        receiveMessageFullText.value = receiveMessageFullText.value + data.receive.content
         // 滚动到最下面
         await scrollToBottom()
       },
       (error) => {
         message.alert(`对话异常! ${error}`)
-        // TODO @fan:是不是可以复用 stopStream 方法
-        // 标记对话结束
-        conversationInProgress.value = false
-        // 结束 stream 对话
-        conversationInAbortController.value.abort()
+        stopStream()
       },
       () => {
-        // TODO @fan:是不是可以复用 stopStream 方法
-        // 标记对话结束
-        conversationInProgress.value = false
-        // 结束 stream 对话
-        conversationInAbortController.value.abort()
+        stopStream()
       }
     )
-  } finally {
-  }
+  } catch {}
 }
 
+/** 停止 stream 流式调用 */
 const stopStream = async () => {
-  console.log('stopStream...')
   // tip:如果 stream 进行中的 message,就需要调用 controller 结束
   if (conversationInAbortController.value) {
     conversationInAbortController.value.abort()
@@ -407,217 +480,98 @@ const stopStream = async () => {
   conversationInProgress.value = false
 }
 
-// ============== message 数据 =================
-
-/** 消息列表 */
-const messageList = computed(() => {
-  if (list.value.length > 0) {
-    return list.value
-  }
-  // 没有消息时,如果有 systemMessage 则展示它
-  // TODO add by 芋艿:这个消息下面,不能有复制、删除按钮
-  if (activeConversation.value?.systemMessage) {
-    return [
-      {
-        id: 0,
-        type: 'system',
-        content: activeConversation.value.systemMessage
-      }
-    ]
-  }
-  return []
-})
-
-// TODO @fan:一般情况下,项目方法注释用 /** */,啊哈,主要保持风格统一,= = 少占点行哈,
-/**
- * 获取 - message 列表
- */
-const getMessageList = async () => {
-  try {
-    // time 定时器,如果加载速度很快,就不进入加载中
-    listLoadingTime.value = setTimeout(() => {
-      listLoading.value = true
-    }, 60)
-    if (activeConversationId.value === null) {
-      return
-    }
-    // 获取列表数据
-    list.value = await ChatMessageApi.messageList(activeConversationId.value)
-    // 滚动到最下面
-    await nextTick(() => {
-      // 滚动到最后
-      scrollToBottom()
-    })
-  } finally {
-    // time 定时器,如果加载速度很快,就不进入加载中
-    if (listLoadingTime.value) {
-      clearTimeout(listLoadingTime.value)
-    }
-    // 加载结束
-    listLoading.value = false
-  }
-}
-
-/** 修改聊天对话 */
-const chatConversationUpdateFormRef = ref()
-const openChatConversationUpdateForm = async () => {
-  chatConversationUpdateFormRef.value.open(activeConversationId.value)
-}
-
-/**
- * 对话 - 标题修改成功
- */
-const handlerTitleSuccess = async () => {
-  // TODO 需要刷新 对话列表
-  await getConversation(activeConversationId.value)
-}
-
-/**
- * 对话 - 创建
- */
-const handleConversationCreate = async () => {
-  // 创建新的对话,清空输入框
-  prompt.value = ''
-}
-
-/**
- * 对话 - 点击
- */
-const handleConversationClick = async (conversation: ChatConversationVO) => {
-  // 对话进行中,不允许切换
-  if (conversationInProgress.value) {
-    await message.alert('对话中,不允许切换!')
-    return false
-  }
-
-  // 更新选中的对话 id
-  activeConversationId.value = conversation.id
-  activeConversation.value = conversation
-  // 处理进行中的对话
-  if (conversationInProgress.value) {
-    await stopStream()
-  }
-  // 刷新 message 列表
-  await getMessageList()
-  // 滚动底部
-  scrollToBottom(true)
-  // 清空输入框
-  prompt.value = ''
-  return true
-}
-
-/**
- * 对话 - 清理全部对话
- */
-const handlerConversationClear = async () => {
-  // TODO @fan:需要加一个 对话进行中,不允许切换
-  activeConversationId.value = null
-  activeConversation.value = null
-  list.value = []
-}
-
-/**
- * 对话 - 删除
- */
-const handlerConversationDelete = async (delConversation: ChatConversationVO) => {
-  // 删除的对话如果是当前选中的,那么就重置
-  if (activeConversationId.value === delConversation.id) {
-    await handlerConversationClear()
-  }
-}
-
-/**
- * 对话 - 获取
- */
-const getConversation = async (id: string | null) => {
-  if (!id) {
-    return
-  }
-  const conversation: ChatConversationVO = await ChatConversationApi.getChatConversationMy(id)
-  if (conversation) {
-    activeConversation.value = conversation
-    activeConversationId.value = conversation.id
-  }
+/** 编辑 message:设置为 prompt,可以再次编辑 */
+const handleMessageEdit = (message: ChatMessageVO) => {
+  prompt.value = message.content
 }
 
-/**
- * 对话 - 新建
- */
-// TODO @fan:应该是 handleXXX,handler 是名词哈
-const handlerNewChat = async () => {
-  // 创建对话
-  await conversationRef.value.createConversation()
+/** 刷新 message:基于指定消息,再次发起对话 */
+const handleMessageRefresh = (message: ChatMessageVO) => {
+  doSendMessage(message.content)
 }
 
-// ============ message ===========
+// ============== 【消息滚动】相关 =============
 
-/**
- * 删除 message
- */
-const handlerMessageDelete = async () => {
-  if (conversationInProgress.value) {
-    message.alert('回答中,不能删除!')
-    return
+/** 滚动到 message 底部 */
+const scrollToBottom = async (isIgnore?: boolean) => {
+  await nextTick()
+  if (messageRef.value) {
+    messageRef.value.scrollToBottom(isIgnore)
   }
-  // 刷新 message
-  await getMessageList()
 }
 
-/**
- * 编辑 message:设置为 prompt,可以再次编辑
- */
-const handlerMessageEdit = async (message: ChatMessageVO) => {
-  prompt.value = message.content
-}
-
-/**
- * 刷新 message:基于指定消息,再次发起对话
- */
-const handlerMessageRefresh = async (message: ChatMessageVO) => {
-  await doSend(message.content)
-}
+/** 自提滚动效果 */
+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
+      }
 
-/**
- * 回到顶部
- */
-const handlerGoTop = async () => {
-  await messageRef.value.handlerGoTop()
-}
+      if (index < receiveMessageFullText.value.length) {
+        receiveMessageDisplayedText.value += receiveMessageFullText.value[index]
+        index++
 
-/**
- * message 清除
- */
-const handlerMessageClear = async () => {
-  if (!activeConversationId.value) {
-    return
-  }
-  // TODO @fan:需要 try catch 下,不然点击取消会报异常
-  // 确认提示
-  await message.delConfirm('确认清空对话消息?')
-  // 清空对话
-  await ChatMessageApi.deleteByConversationId(activeConversationId.value as string)
-  // TODO @fan:是不是直接置空就好啦;
-  // 刷新 message 列表
-  await getMessageList()
+        // 更新 message
+        const lastMessage = activeMessageList.value[activeMessageList.value.length - 1]
+        lastMessage.content = receiveMessageDisplayedText.value
+        // 滚动到住下面
+        await scrollToBottom()
+        // 重新设置任务
+        timer = setTimeout(task, textSpeed.value)
+      } else {
+        // 不是对话中可以结束
+        if (!conversationInProgress.value) {
+          textRoleRunning.value = false
+          clearTimeout(timer)
+        } else {
+          // 重新设置任务
+          timer = setTimeout(task, textSpeed.value)
+        }
+      }
+    }
+    let timer = setTimeout(task, textSpeed.value)
+  } catch {}
 }
 
 /** 初始化 **/
 onMounted(async () => {
-  // 设置当前对话 TODO 角色仓库过来的,自带 conversationId 需要选中
+  // 如果有 conversationId 参数,则默认选中
   if (route.query.conversationId) {
-    const id = route.query.conversationId as string
+    const id = route.query.conversationId as unknown as number
     activeConversationId.value = id
     await getConversation(id)
   }
+
   // 获取列表数据
-  listLoading.value = true
+  activeMessageListLoading.value = true
   await getMessageList()
 })
 </script>
 
 <style lang="scss" scoped>
 .ai-layout {
-  // TODO @范 这里height不能 100% 先这样临时处理
   position: absolute;
   flex: 1;
   top: 0;
@@ -631,8 +585,7 @@ onMounted(async () => {
   display: flex;
   flex-direction: column;
   justify-content: space-between;
-  padding: 0 10px;
-  padding-top: 10px;
+  padding: 10px 10px 0;
 
   .btn-new-conversation {
     padding: 18px 0;
@@ -771,8 +724,6 @@ onMounted(async () => {
     bottom: 0;
     left: 0;
     right: 0;
-    //width: 100%;
-    //height: 100%;
     overflow-y: hidden;
     padding: 0;
     margin: 0;
@@ -803,8 +754,7 @@ onMounted(async () => {
     border: none;
     box-sizing: border-box;
     resize: none;
-    padding: 0px 2px;
-    //padding: 5px 5px;
+    padding: 0 2px;
     overflow: auto;
   }
 
@@ -815,7 +765,7 @@ onMounted(async () => {
   .prompt-btns {
     display: flex;
     justify-content: space-between;
-    padding-bottom: 0px;
+    padding-bottom: 0;
     padding-top: 5px;
   }
 }

+ 1 - 1
src/views/ai/chat/manager/index.vue

@@ -16,5 +16,5 @@ import ChatConversationList from './ChatConversationList.vue'
 import ChatMessageList from './ChatMessageList.vue'
 
 /** AI 聊天对话 列表 */
-defineOptions({ name: 'ChatConversation' })
+defineOptions({ name: 'AiChatManager' })
 </script>

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

@@ -1,145 +0,0 @@
-<template>
-  <el-drawer
-    v-model="showDrawer"
-    title="图片详细"
-    @close="handlerDrawerClose"
-    custom-class="drawer-class"
-  >
-    <!-- 图片 -->
-    <div class="item">
-<!--      <div class="header">-->
-<!--        <div>图片</div>-->
-<!--        <div>-->
-<!--        </div>-->
-<!--      </div>-->
-      <div class="body">
-        <!-- TODO @fan: 要不,这里只展示图片???不用 ImageTaskCard -->
-        <ImageTaskCard :image-detail="imageDetail" />
-      </div>
-    </div>
-    <!--  时间  -->
-    <div class="item">
-      <div class="tip">时间</div>
-      <div class="body">
-        <div>提交时间:{{imageDetail.createTime}}</div>
-        <!-- TODO @fan:要不加个完成时间的字段 finishTime?updateTime 不算特别合理哈 -->
-        <div>生成时间:{{imageDetail.updateTime}}</div>
-      </div>
-    </div>
-    <!--  模型  -->
-    <div class="item">
-      <div class="tip">模型</div>
-      <div class="body">
-        {{imageDetail.model}}({{imageDetail.height}}x{{imageDetail.width}})
-      </div>
-    </div>
-    <!--  提示词  -->
-    <div class="item">
-      <div class="tip">提示词</div>
-      <div class="body">
-        {{imageDetail.prompt}}
-      </div>
-    </div>
-    <!--  地址  -->
-    <div class="item">
-      <div class="tip">图片地址</div>
-      <div class="body">
-        {{imageDetail.picUrl}}
-      </div>
-    </div>
-    <!-- 风格 -->
-    <div class="item" v-if="imageDetail?.options?.style">
-      <div class="tip">风格</div>
-      <div class="body">
-        <!-- TODO @fan:貌似需要把 imageStyleList 搞到 api/image/index.ts 枚举起来? -->
-        <!-- TODO @fan:这里的展示,可能需要按照平台做区分 -->
-        {{imageDetail?.options?.style}}
-      </div>
-    </div>
-  </el-drawer>
-</template>
-
-<script setup lang="ts">
-import {ImageApi, ImageDetailVO} from '@/api/ai/image';
-import ImageTaskCard from './ImageTaskCard.vue';
-
-const showDrawer = ref<boolean>(false) // 是否显示
-const imageDetail = ref<ImageDetailVO>({} as ImageDetailVO) // 图片详细信息
-
-const props = defineProps({
-  show: {
-    type: Boolean,
-    require: true,
-    default: false
-  },
-  id: {
-    type: Number,
-    required: true
-  }
-})
-
-/**  抽屉 - close  */
-const handlerDrawerClose = async () => {
-  emits('handlerDrawerClose')
-}
-
-/**  获取 - 图片 detail  */
-const getImageDetail = async (id) => {
-  // 获取图片详细
-  imageDetail.value = await ImageApi.getImageDetail(id)
-}
-
-/**  任务 - detail  */
-const handlerTaskDetail = async () => {
-  showDrawer.value = true
-}
-
-// watch show
-const { show } = toRefs(props)
-watch(show, async (newValue, oldValue) => {
-  showDrawer.value = newValue as boolean
-})
-// watch id
-const { id } = toRefs(props)
-watch(id, async (newVal, oldVal) => {
-  if (newVal) {
-    await getImageDetail(newVal)
-  }
-})
-//
-const emits = defineEmits(['handlerDrawerClose'])
-//
-onMounted(async () => {
-
-})
-</script>
-<style scoped lang="scss">
-
-.item {
-  margin-bottom: 20px;
-  width: 100%;
-  overflow: hidden;
-  word-wrap: break-word;
-
-  .header {
-    display: flex;
-    flex-direction: row;
-    justify-content: space-between;
-  }
-
-  .tip {
-    font-weight: bold;
-    font-size: 16px;
-  }
-
-  .body {
-    margin-top: 10px;
-    color: #616161;
-
-
-    .taskImage {
-      border-radius: 10px;
-    }
-  }
-}
-</style>

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

@@ -1,210 +0,0 @@
-<template>
-  <el-card class="dr-task" body-class="task-card" shadow="never">
-    <template #header>绘画任务</template>
-    <div class="task-image-list" ref="imageTaskRef">
-      <ImageTaskCard
-        v-for="image in imageList"
-        :key="image"
-        :image-detail="image"
-
-        @on-btn-click="handlerImageBtnClick"
-        @on-mj-btn-click="handlerImageMjBtnClick"/>
-    </div>
-    <div class="task-image-pagination">
-      <el-pagination background layout="prev, pager, next"
-                     :default-page-size="pageSize"
-                     :total="pageTotal"
-                     @change="handlerPageChange"
-      />
-    </div>
-  </el-card>
-  <!-- 图片 detail 抽屉 -->
-  <ImageDetailDrawer
-    :show="isShowImageDetail"
-    :id="showImageDetailId"
-    @handler-drawer-close="handlerDrawerClose"
-  />
-</template>
-<script setup lang="ts">
-import {ImageApi, ImageDetailVO, ImageMjActionVO, ImageMjButtonsVO} from '@/api/ai/image';
-import ImageDetailDrawer from './ImageDetailDrawer.vue'
-import ImageTaskCard from './ImageTaskCard.vue'
-import {ElLoading, LoadingOptionsResolved} from "element-plus";
-
-const message = useMessage() // 消息弹窗
-
-const imageList = ref<ImageDetailVO[]>([]) // image 列表
-const imageListInterval = ref<any>() // image 列表定时器,刷新列表
-const isShowImageDetail = ref<boolean>(false) // 是否显示 task 详情
-const showImageDetailId = ref<number>(0) // 是否显示 task 详情
-const imageTaskRef = ref<any>() // ref
-const imageTaskLoadingInstance = ref<any>() // loading
-const imageTaskLoading = ref<boolean>(false) // loading
-const pageNo = ref<number>(1) // page no
-const pageSize = ref<number>(10) // page size
-const pageTotal = ref<number>(0) // page size
-
-/**  抽屉 - close  */
-const handlerDrawerClose = async () => {
-  isShowImageDetail.value = false
-}
-
-/**  任务 - detail  */
-const handlerDrawerOpen = async () => {
-  isShowImageDetail.value = true
-}
-
-/**
- * 获取 - image 列表
- */
-const getImageList = async (apply:boolean = false) => {
-  imageTaskLoading.value = true
-  try {
-    imageTaskLoadingInstance.value = ElLoading.service({
-      target: imageTaskRef.value,
-      text: '加载中...'
-    } as LoadingOptionsResolved)
-    const { list, total } = await ImageApi.getImageList({pageNo: pageNo.value, pageSize: pageSize.value})
-    if (apply) {
-      imageList.value = [...imageList.value, ...list]
-    } else {
-      imageList.value = list
-    }
-    pageTotal.value = total
-  } finally {
-    if (imageTaskLoadingInstance.value) {
-      imageTaskLoadingInstance.value.close();
-      imageTaskLoadingInstance.value = null;
-    }
-  }
-}
-
-/**  图片 - btn click  */
-const handlerImageBtnClick = async (type, imageDetail: ImageDetailVO) => {
-  // 获取 image detail id
-  showImageDetailId.value = imageDetail.id
-  console.log('type', imageDetail.id)
-  // 处理不用 btn
-  if (type === 'more') {
-    await handlerDrawerOpen()
-  } else if (type === 'delete') {
-    await message.confirm(`是否删除照片?`)
-    await ImageApi.deleteImage(imageDetail.id)
-    await getImageList()
-    await message.success("删除成功!")
-  } else if (type === 'download') {
-    await downloadImage(imageDetail.picUrl)
-  }
-}
-
-/**  图片 - mj btn click  */
-const handlerImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageDetailVO) => {
-  // 1、构建 params 参数
-  const params = {
-    id: imageDetail.id,
-    customId: button.customId,
-  } as ImageMjActionVO
-  // 2、发送 action
-  await ImageApi.midjourneyAction(params)
-  // 3、刷新列表
-  await getImageList()
-}
-
-/**  下载 - image  */
-// TODO @fan:貌似可以考虑抽到 download 里面,作为一个方法
-const downloadImage = async (imageUrl) => {
-  const image = new Image()
-  image.setAttribute('crossOrigin', 'anonymous')
-  image.src = imageUrl
-  image.onload = () => {
-    const canvas = document.createElement('canvas')
-    canvas.width = image.width
-    canvas.height = image.height
-    const ctx = canvas.getContext('2d') as CanvasDrawImage
-    ctx.drawImage(image, 0, 0, image.width, image.height)
-    const url = canvas.toDataURL('image/png')
-    const a = document.createElement('a')
-    a.href = url
-    a.download = 'image.png'
-    a.click()
-  }
-}
-
-// page change
-const handlerPageChange = async (page) => {
-  pageNo.value = page
-  await getImageList(false)
-}
-
-/** 暴露组件方法 */
-defineExpose({getImageList})
-
-/** 组件挂在的时候 */
-onMounted(async () => {
-  // 获取 image 列表
-  await getImageList()
-  // 自动刷新 image 列表
-  imageListInterval.value = setInterval(async () => {
-    await getImageList(false)
-  }, 1000 * 20)
-})
-
-/** 组件取消挂在的时候 */
-onUnmounted(async () => {
-  if (imageListInterval.value) {
-    clearInterval(imageListInterval.value)
-  }
-})
-</script>
-
-<style lang="scss">
-.task-card {
-  margin: 0;
-  padding: 0;
-  height: 100%;
-  position: relative;
-}
-
-.task-image-list {
-  position: relative;
-  display: flex;
-  flex-direction: row;
-  flex-wrap: wrap;
-  align-content: flex-start;
-  height: 100%;
-  overflow: auto;
-  padding: 20px;
-  padding-bottom: 140px;
-  box-sizing: border-box; /* 确保内边距不会增加高度 */
-
-  >div {
-    margin-right: 20px;
-    margin-bottom: 20px;
-  }
-  >div:last-of-type {
-    //margin-bottom: 100px;
-  }
-}
-
-.task-image-pagination {
-  position: absolute;
-  bottom: 60px;
-  height: 50px;
-  line-height: 90px;
-  width: 100%;
-  z-index: 999;
-  background-color: #ffffff;
-  display: flex;
-  flex-direction: row;
-  justify-content: center;
-  align-items: center;
-}
-
-</style>
-
-<style scoped lang="scss">
-.dr-task {
-  width: 100%;
-  height: 100%;
-}
-</style>

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

@@ -1,135 +0,0 @@
-<template>
-  <el-card body-class="" class="image-card">
-    <div class="image-operation">
-      <div>
-        <el-button type="primary" text bg v-if="imageDetail?.status === 10">生成中</el-button>
-        <el-button text bg v-else-if="imageDetail?.status === 20">已完成</el-button>
-        <el-button type="danger" text bg v-else-if="imageDetail?.status === 30">异常</el-button>
-      </div>
-      <!-- TODO @fan:1)按钮要不调整成详情、下载、再次生成、删除?;2)如果是再次生成,就把当前的参数填写到左侧的框框里? -->
-      <div>
-        <el-button class="btn" text :icon="Download"
-                   @click="handlerBtnClick('download', imageDetail)"/>
-        <el-button class="btn" text :icon="Delete" @click="handlerBtnClick('delete', imageDetail)"/>
-        <el-button class="btn" text :icon="More" @click="handlerBtnClick('more', imageDetail)"/>
-      </div>
-    </div>
-    <div class="image-wrapper" ref="cardImageRef">
-      <!-- TODO @fan:要不加个点击,大图预览? -->
-      <img class="image" :src="imageDetail?.picUrl"/>
-      <div v-if="imageDetail?.status === 30">{{imageDetail?.errorMessage}}</div>
-    </div>
-    <!-- TODO @fan:style 使用 unocss 替代下 -->
-    <div class="image-mj-btns">
-      <el-button size="small" v-for="button in imageDetail?.buttons" :key="button"
-                 style="min-width: 40px;margin-left: 0; margin-right: 10px; margin-top: 5px;"
-                 @click="handlerMjBtnClick(button)"
-      >
-        {{ button.label }}{{ button.emoji }}
-      </el-button>
-    </div>
-  </el-card>
-</template>
-<script setup lang="ts">
-import {Delete, Download, More} from "@element-plus/icons-vue";
-import {ImageDetailVO, ImageMjButtonsVO} from "@/api/ai/image";
-import {PropType} from "vue";
-import {ElLoading, ElMessageBox} from "element-plus";
-
-const cardImageRef = ref<any>() // 卡片 image ref
-const cardImageLoadingInstance = ref<any>() // 卡片 image ref
-const message = useMessage()
-const props = defineProps({
-  imageDetail: {
-    type: Object as PropType<ImageDetailVO>,
-    require: true
-  }
-})
-
-/**  按钮 - 点击事件  */
-const handlerBtnClick = async (type, imageDetail: ImageDetailVO) => {
-  emits('onBtnClick', type, imageDetail)
-}
-
-const handlerLoading = async (status: number) => {
-  // TODO @fan:这个搞成 Loading 组件,然后通过数据驱动,这样搞可以哇?
-  if (status === 10) {
-    cardImageLoadingInstance.value = ElLoading.service({
-      target: cardImageRef.value,
-      text: '生成中...'
-    })
-  } else {
-    if (cardImageLoadingInstance.value) {
-      cardImageLoadingInstance.value.close();
-      cardImageLoadingInstance.value = null;
-    }
-  }
-}
-
-/**  mj 按钮 click  */
-const handlerMjBtnClick = async (button: ImageMjButtonsVO) => {
-  // 确认窗体
-  await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`)
-  emits('onMjBtnClick', button, props.imageDetail)
-}
-
-// watch
-const { imageDetail } = toRefs(props)
-watch(imageDetail, async (newVal, oldVal) => {
-  await handlerLoading(newVal.status as string)
-})
-
-// emits
-const emits = defineEmits(['onBtnClick', 'onMjBtnClick'])
-
-//
-onMounted(async () => {
-  await handlerLoading(props.imageDetail.status as string)
-})
-</script>
-
-<style scoped lang="scss">
-
-.image-card {
-  width: 320px;
-  height: auto;
-  border-radius: 10px;
-  position: relative;
-  display: flex;
-  flex-direction: column;
-
-  .image-operation {
-    display: flex;
-    flex-direction: row;
-    justify-content: space-between;
-
-    .btn {
-      //border: 1px solid red;
-      padding: 10px;
-      margin: 0;
-    }
-  }
-
-  .image-wrapper {
-    overflow: hidden;
-    margin-top: 20px;
-    height: 280px;
-    flex: 1;
-
-    .image {
-      width: 100%;
-      border-radius: 10px;
-    }
-  }
-
-  .image-mj-btns {
-    margin-top: 5px;
-    width: 100%;
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    justify-content: flex-start;
-  }
-}
-
-</style>

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

@@ -1,398 +0,0 @@
-<!-- dall3 -->
-<template>
-  <div class="prompt">
-    <el-text tag="b">画面描述</el-text>
-    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
-    <!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
-    <el-input
-      v-model="prompt"
-      maxlength="1024"
-      rows="5"
-      style="width: 100%; margin-top: 15px;"
-      input-style="border-radius: 7px;"
-      placeholder="例如:童话里的小屋应该是什么样子?"
-      show-word-limit
-      type="textarea"
-    />
-  </div>
-  <div class="hot-words">
-    <div>
-      <el-text tag="b">随机热词</el-text>
-    </div>
-    <el-space wrap class="word-list">
-      <el-button round
-                 class="btn"
-                 :type="(selectHotWord === hotWord ? 'primary' : 'default')"
-                 v-for="hotWord in hotWords"
-                 :key="hotWord"
-                 @click="handlerHotWordClick(hotWord)"
-      >
-        {{ hotWord }}
-      </el-button>
-    </el-space>
-  </div>
-  <div class="model">
-    <div>
-      <el-text tag="b">模型选择</el-text>
-    </div>
-    <el-space wrap class="model-list">
-      <div
-        :class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
-        v-for="model in models"
-        :key="model.key"
-
-      >
-        <el-image
-          :src="model.image"
-          fit="contain"
-          @click="handlerModelClick(model)"
-        />
-        <div class="model-font">{{model.name}}</div>
-      </div>
-    </el-space>
-  </div>
-  <div class="image-style">
-    <div>
-      <el-text tag="b">风格选择</el-text>
-    </div>
-    <el-space wrap class="image-style-list">
-      <div
-        :class="selectImageStyle === imageStyle ? 'image-style-item selectImageStyle' : 'image-style-item'"
-        v-for="imageStyle in imageStyleList"
-        :key="imageStyle.key"
-      >
-        <el-image
-          :src="imageStyle.image"
-          fit="contain"
-          @click="handlerStyleClick(imageStyle)"
-        />
-        <div class="style-font">{{imageStyle.name}}</div>
-      </div>
-    </el-space>
-  </div>
-  <div class="image-size">
-    <div>
-      <el-text tag="b">画面比例</el-text>
-    </div>
-    <el-space wrap class="size-list">
-      <div class="size-item"
-           v-for="imageSize in imageSizeList"
-           :key="imageSize.key"
-           @click="handlerSizeClick(imageSize)">
-        <div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
-          <div :style="imageSize.style"></div>
-        </div>
-        <div class="size-font">{{ imageSize.name }}</div>
-      </div>
-    </el-space>
-  </div>
-  <div class="btns">
-    <el-button type="primary"
-               size="large"
-               round
-               :loading="drawIn"
-               @click="handlerGenerateImage">
-      {{drawIn ? '生成中' : '生成内容'}}
-    </el-button>
-  </div>
-</template>
-<script setup lang="ts">
-import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
-
-// image 模型
-interface ImageModelVO {
-  key: string
-  name: string
-  image: string
-}
-
-// image 大小
-interface ImageSizeVO {
-  key: string
-  name: string,
-  style: string,
-  width: string,
-  height: string,
-}
-
-// 定义属性
-const prompt = ref<string>('')  // 提示词
-const drawIn = ref<boolean>(false)  // 生成中
-const selectHotWord = ref<string>('') // 选中的热词
-const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
-const selectModel = ref<any>({}) // 模型
-// message
-const message = useMessage()
-// TODO @fan:image 改成项目里自己的哈
-// TODO @fan:这个 image,要不看看网上有没合适的图片,作为占位符,啊哈哈
-const models = ref<ImageModelVO[]>([
-  {
-    key: 'dall-e-3',
-    name: 'DALL·E 3',
-    image: 'https://h5.cxyhub.com/images/model_2.png',
-  },
-  {
-    key: 'dall-e-2',
-    name: 'DALL·E 2',
-    image: 'https://h5.cxyhub.com/images/model_1.png',
-  },
-])  // 模型
-selectModel.value = models.value[0]
-
-const selectImageStyle = ref<any>({}) // style 样式
-// TODO @fan:image 改成项目里自己的哈
-const imageStyleList = ref<ImageModelVO[]>([
-  {
-    key: 'vivid',
-    name: '清晰',
-    image: 'https://h5.cxyhub.com/images/model_1.png',
-  },
-  {
-    key: 'natural',
-    name: '自然',
-    image: 'https://h5.cxyhub.com/images/model_2.png',
-  },
-])  // style
-selectImageStyle.value = imageStyleList.value[0]
-
-const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
-const imageSizeList = ref<ImageSizeVO[]>([
-  {
-    key: '1024x1024',
-    name: '1:1',
-    width: '1024',
-    height: '1024',
-    style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
-  },
-  {
-    key: '1024x1792',
-    name: '3:5',
-    width: '1024',
-    height: '1792',
-    style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
-  },
-  {
-    key: '1792x1024',
-    name: '5:3',
-    width: '1792',
-    height: '1024',
-    style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
-  }
-]) // size
-selectImageSize.value = imageSizeList.value[0]
-
-// 定义 Props
-const props = defineProps({})
-// 定义 emits
-const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
-
-// TODO @fan:如果是简单注释,建议用 /** */,主要是现在项目里是这种风格哈,保持一致好点~
-// TODO @fan:handler 应该改成 handle 哈
-/** 热词 - click  */
-const handlerHotWordClick = async (hotWord: string) => {
-  // 取消选中
-  if (selectHotWord.value == hotWord) {
-    selectHotWord.value = ''
-    return
-  }
-  // 选中
-  selectHotWord.value = hotWord
-  // 替换提示词
-  prompt.value = hotWord
-}
-
-/**  模型 - click  */
-const handlerModelClick = async (model: ImageModelVO) => {
-  if (selectModel.value === model) {
-    selectModel.value = {} as ImageModelVO
-    return
-  }
-  selectModel.value = model
-}
-
-/**  样式 - click  */
-const handlerStyleClick = async (imageStyle: ImageModelVO) => {
-  if (selectImageStyle.value === imageStyle) {
-    selectImageStyle.value = {} as ImageModelVO
-    return
-  }
-  selectImageStyle.value = imageStyle
-}
-
-/**  size - click  */
-const handlerSizeClick = async (imageSize: ImageSizeVO) => {
-  if (selectImageSize.value === imageSize) {
-    selectImageSize.value = {} as ImageSizeVO
-    return
-  }
-  selectImageSize.value = imageSize
-}
-
-/**  图片生产  */
-const handlerGenerateImage = async () => {
-  // 二次确认
-  await message.confirm(`确认生成内容?`)
-  try {
-    // 加载中
-    drawIn.value = true
-    // 回调
-    emits('onDrawStart', selectModel.value.key)
-    const form = {
-      platform: 'OpenAI',
-      prompt: prompt.value, // 提示词
-      model: selectModel.value.key, // 模型
-      width: selectImageSize.value.width, // size 不能为空
-      height: selectImageSize.value.height, // size 不能为空
-      options: {
-        style: selectImageStyle.value.key, // 图像生成的风格
-      }
-    } as ImageDrawReqVO
-    // 发送请求
-    await ImageApi.drawImage(form)
-  } finally {
-    // 回调
-    emits('onDrawComplete', selectModel.value.key)
-    // 加载结束
-    drawIn.value = false
-  }
-}
-
-</script>
-<style scoped lang="scss">
-
-// 提示词
-.prompt {
-}
-
-// 热词
-.hot-words {
-  display: flex;
-  flex-direction: column;
-  margin-top: 30px;
-
-  .word-list {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    justify-content: start;
-    margin-top: 15px;
-
-    .btn {
-      margin: 0;
-    }
-  }
-}
-
-// 模型
-.model {
-  margin-top: 30px;
-
-  .model-list {
-    margin-top: 15px;
-
-    .modal-item {
-      width: 110px;
-      //outline: 1px solid blue;
-      overflow: hidden;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      border: 3px solid transparent;
-      cursor: pointer;
-
-      .model-font {
-        font-size: 14px;
-        color: #3e3e3e;
-        font-weight: bold;
-      }
-    }
-
-    .selectModel {
-      border: 3px solid #1293ff;
-      border-radius: 5px;
-    }
-  }
-}
-
-
-// 样式 style
-.image-style {
-  margin-top: 30px;
-
-  .image-style-list {
-    margin-top: 15px;
-
-    .image-style-item {
-      width: 110px;
-      //outline: 1px solid blue;
-      overflow: hidden;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      border: 3px solid transparent;
-      cursor: pointer;
-
-      .style-font {
-        font-size: 14px;
-        color: #3e3e3e;
-        font-weight: bold;
-      }
-    }
-
-    .selectImageStyle {
-      border: 3px solid #1293ff;
-      border-radius: 5px;
-    }
-  }
-}
-
-// 尺寸
-.image-size {
-  width: 100%;
-  margin-top: 30px;
-
-  .size-list {
-    display: flex;
-    flex-direction: row;
-    justify-content: space-between;
-    width: 100%;
-    margin-top: 20px;
-
-    .size-item {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      cursor: pointer;
-
-      .size-wrapper {
-        display: flex;
-        flex-direction: column;
-        align-items: center;
-        justify-content: center;
-        border-radius: 7px;
-        padding: 4px;
-        width: 50px;
-        height: 50px;
-        background-color: #fff;
-        border: 1px solid #fff;
-      }
-
-      .size-font {
-        font-size: 14px;
-        color: #3e3e3e;
-        font-weight: bold;
-      }
-    }
-  }
-
-  .selectImageSize {
-    border: 1px solid #1293ff !important;
-  }
-}
-
-.btns {
-  display: flex;
-  justify-content: center;
-  margin-top: 50px;
-}
-</style>

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

@@ -1,105 +0,0 @@
-<!-- image -->
-<template>
-  <div class="ai-image">
-    <div class="left">
-      <div class="segmented">
-        <el-segmented v-model="selectModel" :options="modelOptions" />
-      </div>
-      <div class="modal-switch-container">
-        <!-- TODO @fan:1)建议 Dall3 改成 OpenAI 绘图。因为 dall3 其实本质是模型;2)涉及到中英文的地方,中文和英文之间,有个空格哈 -->
-        <Dall3 v-if="selectModel === 'DALL3绘画'"
-               @on-draw-start="handlerDrawStart"
-               @on-draw-complete="handlerDrawComplete" />
-        <Midjourney v-if="selectModel === 'MJ绘画'" />
-        <StableDiffusion v-if="selectModel === 'Stable Diffusion'" />
-      </div>
-    </div>
-    <div class="main">
-      <ImageTask ref="imageTaskRef" />
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-// TODO @fan:在整个挪到 /views/ai/image/index 目录。因为我想在 /views/ai/image/manager 做管理的功能,进行下区分!
-import Dall3 from './dall3/index.vue'
-import Midjourney from './midjourney/index.vue'
-import StableDiffusion from './stable-diffusion/index.vue'
-import ImageTask from './ImageTask.vue'
-
-// ref
-const imageTaskRef = ref<any>() // image task ref
-
-// 定义属性
-const selectModel = ref('Stable Diffusion')
-const modelOptions = ['DALL3绘画', 'MJ绘画', 'Stable Diffusion']
-const drawIn = ref<boolean>(false)  // 生成中
-
-/**  绘画 - start  */
-const handlerDrawStart = async (type) => {
-  // todo
-  drawIn.value = true
-}
-
-/**  绘画 - complete  */
-const handlerDrawComplete = async (type) => {
-  drawIn.value = false
-  // todo
-  await imageTaskRef.value.getImageList()
-}
-
-//
-onMounted( async () => {
-})
-
-</script>
-
-<style scoped lang="scss">
-
-.ai-image {
-  position: absolute;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  top: 0;
-
-  display: flex;
-  flex-direction: row;
-  height: 100%;
-  width: 100%;
-
-  .left {
-    display: flex;
-    flex-direction: column;
-    padding: 20px;
-    width: 350px;
-
-    .segmented {
-    }
-
-    .segmented .el-segmented {
-      --el-border-radius-base: 16px;
-      --el-segmented-item-selected-color: #fff;
-      background-color: #ececec;
-      width: 350px;
-    }
-
-    .modal-switch-container {
-      height: 100%;
-      overflow-y: auto;
-      margin-top: 30px;
-    }
-  }
-
-  .main {
-    flex: 1;
-    background-color: #fff;
-  }
-
-  .right {
-    width: 350px;
-    background-color: #f7f8fa;
-  }
-}
-
-</style>

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

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

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

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

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

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

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

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

@@ -0,0 +1,141 @@
+<!-- image -->
+<template>
+  <div class="ai-image">
+    <div class="left">
+      <div class="segmented">
+        <el-segmented v-model="selectPlatform" :options="platformOptions" />
+      </div>
+      <div class="modal-switch-container">
+        <Dall3
+          v-if="selectPlatform === AiPlatformEnum.OPENAI"
+          ref="dall3Ref"
+          @on-draw-start="handleDrawStart"
+          @on-draw-complete="handleDrawComplete"
+        />
+        <Midjourney v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY" ref="midjourneyRef" />
+        <StableDiffusion
+          v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
+          ref="stableDiffusionRef"
+          @on-draw-complete="handleDrawComplete"
+        />
+        <Other
+          v-if="selectPlatform === 'other'"
+          ref="otherRef"
+          @on-draw-complete="handleDrawComplete"
+        />
+      </div>
+    </div>
+    <div class="main">
+      <ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ImageList from './components/ImageList.vue'
+import { AiPlatformEnum } from '@/views/ai/utils/constants'
+import { ImageVO } from '@/api/ai/image'
+import Dall3 from './components/dall3/index.vue'
+import Midjourney from './components/midjourney/index.vue'
+import StableDiffusion from './components/stableDiffusion/index.vue'
+import Other from './components/other/index.vue'
+
+const imageListRef = ref<any>() // image 列表 ref
+const dall3Ref = ref<any>() // dall3(openai) ref
+const midjourneyRef = ref<any>() // midjourney ref
+const stableDiffusionRef = ref<any>() // stable diffusion ref
+const otherRef = ref<any>() // stable diffusion ref
+
+// 定义属性
+const selectPlatform = ref(AiPlatformEnum.MIDJOURNEY)
+const platformOptions = [
+  {
+    label: 'DALL3 绘画',
+    value: AiPlatformEnum.OPENAI
+  },
+  {
+    label: 'MJ 绘画',
+    value: AiPlatformEnum.MIDJOURNEY
+  },
+  {
+    label: 'Stable Diffusion',
+    value: AiPlatformEnum.STABLE_DIFFUSION
+  },
+  {
+    label: '其它',
+    value: 'other'
+  }
+]
+
+/** 绘画 start  */
+const handleDrawStart = async (platform: string) => {}
+
+/** 绘画 complete */
+const handleDrawComplete = async (platform: string) => {
+  await imageListRef.value.getImageList()
+}
+
+/**  重新生成:将画图详情填充到对应平台  */
+const handleRegeneration = async (image: ImageVO) => {
+  // 切换平台
+  selectPlatform.value = image.platform
+  // 根据不同平台填充 image
+  await nextTick()
+  if (image.platform === AiPlatformEnum.MIDJOURNEY) {
+    midjourneyRef.value.settingValues(image)
+  } else if (image.platform === AiPlatformEnum.OPENAI) {
+    dall3Ref.value.settingValues(image)
+  } else if (image.platform === AiPlatformEnum.STABLE_DIFFUSION) {
+    stableDiffusionRef.value.settingValues(image)
+  }
+  // TODO @fan:貌似 other 重新设置不行?
+}
+</script>
+
+<style scoped lang="scss">
+.ai-image {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  top: 0;
+
+  display: flex;
+  flex-direction: row;
+  height: 100%;
+  width: 100%;
+
+  .left {
+    display: flex;
+    flex-direction: column;
+    padding: 20px;
+    width: 350px;
+
+    .segmented {
+    }
+
+    .segmented .el-segmented {
+      --el-border-radius-base: 16px;
+      --el-segmented-item-selected-color: #fff;
+      background-color: #ececec;
+      width: 350px;
+    }
+
+    .modal-switch-container {
+      height: 100%;
+      overflow-y: auto;
+      margin-top: 30px;
+    }
+  }
+
+  .main {
+    flex: 1;
+    background-color: #fff;
+  }
+
+  .right {
+    width: 350px;
+    background-color: #f7f8fa;
+  }
+}
+</style>

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

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

@@ -1,388 +0,0 @@
-<!-- dall3 -->
-<template>
-  <div class="prompt">
-    <el-text tag="b">画面描述</el-text>
-    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开.</el-text>
-    <el-input
-      v-model="prompt"
-      maxlength="1024"
-      rows="5"
-      style="width: 100%; margin-top: 15px;"
-      input-style="border-radius: 7px;"
-      placeholder="例如:童话里的小屋应该是什么样子?"
-      show-word-limit
-      type="textarea"
-    />
-  </div>
-  <div class="hot-words">
-    <div>
-      <el-text tag="b">随机热词</el-text>
-    </div>
-    <el-space wrap class="word-list">
-      <el-button round
-                 class="btn"
-                 :type="(selectHotWord === hotWord ? 'primary' : 'default')"
-                 v-for="hotWord in hotWords"
-                 :key="hotWord"
-                 @click="handlerHotWordClick(hotWord)"
-      >
-        {{ hotWord }}
-      </el-button>
-    </el-space>
-  </div>
-  <div class="image-size">
-    <div>
-      <el-text tag="b">尺寸</el-text>
-    </div>
-    <el-space wrap class="size-list">
-      <div class="size-item"
-           v-for="imageSize in imageSizeList"
-           :key="imageSize.key"
-           @click="handlerSizeClick(imageSize)">
-        <div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
-          <div :style="imageSize.style"></div>
-        </div>
-        <div class="size-font">{{ imageSize.key }}</div>
-      </div>
-    </el-space>
-  </div>
-  <div class="version">
-    <div>
-      <el-text tag="b">版本</el-text>
-    </div>
-    <el-space wrap class="version-list">
-      <el-select
-        v-model="selectVersion"
-        class="version-select"
-        clearable
-        placeholder="请选择版本"
-        style="width: 350px"
-        @change="handlerChangeVersion"
-      >
-        <el-option
-          v-for="item in versionList"
-          :key="item.value"
-          :label="item.label"
-          :value="item.value"
-        />
-      </el-select>
-    </el-space>
-  </div>
-  <div class="model">
-    <div>
-      <el-text tag="b">模型</el-text>
-    </div>
-    <el-space wrap class="model-list">
-      <div
-        :class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
-        v-for="model in models"
-        :key="model.key"
-
-      >
-        <el-image
-          :src="model.image"
-          fit="contain"
-          @click="handlerModelClick(model)"
-        />
-        <div class="model-font">{{model.name}}</div>
-      </div>
-    </el-space>
-  </div>
-  <div class="btns">
-    <!--    <el-button size="large" round>重置内容</el-button>-->
-    <el-button type="primary" size="large" round @click="handlerGenerateImage">生成内容</el-button>
-  </div>
-</template>
-<script setup lang="ts">
-
-// image 模型
-import {ImageApi, ImageMidjourneyImagineReqVO} from "@/api/ai/image";
-// message
-const message = useMessage()
-// 定义 emits
-const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
-
-interface ImageModelVO {
-  key: string
-  name: string
-  image: string
-}
-
-// image 大小
-interface ImageSizeVO {
-  key: string
-  style: string,
-  width: string,
-  height: string,
-}
-
-// 定义属性
-const prompt = ref<string>('')  // 提示词
-const selectHotWord = ref<string>('') // 选中的热词
-const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
-const selectModel = ref<any>() // 选中的热词
-const models = ref<ImageModelVO[]>([
-  {
-    key: 'midjourney',
-    name: 'MJ',
-    image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png',
-  },
-  {
-    key: 'niji',
-    name: 'NIJI',
-    image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png',
-  },
-])  // 模型
-selectModel.value = models.value[0] // 默认选中
-
-const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
-const imageSizeList = ref<ImageSizeVO[]>([
-  {
-    key: '1:1',
-    width: "1",
-    height: "1",
-    style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
-  },
-  {
-    key: '3:4',
-    width: "3",
-    height: "4",
-    style: 'width: 30px; height: 40px;background-color: #dcdcdc;',
-  },
-  {
-    key: '4:3',
-    width: "4",
-    height: "3",
-    style: 'width: 40px; height: 30px;background-color: #dcdcdc;',
-  },
-  {
-    key: '9:16',
-    width: "9",
-    height: "16",
-    style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
-  },
-  {
-    key: '16:9',
-    width: "16",
-    height: "9",
-    style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
-  },
-]) // size
-selectImageSize.value = imageSizeList.value[0]
-
-
-// version
-let versionList = ref<any>([]) // version 列表
-const midjourneyVersionList = ref<any>([
-  {
-    value: '6.0',
-    label: 'v6.0',
-  },
-  {
-    value: '5.2',
-    label: 'v5.2',
-  },
-  {
-    value: '5.1',
-    label: 'v5.1',
-  },
-  {
-    value: '5.0',
-    label: 'v5.0',
-  },
-  {
-    value: '4.0',
-    label: 'v4.0',
-  },
-])
-const nijiVersionList = ref<any>([
-  {
-    value: '5',
-    label: 'v5',
-  },
-])
-const selectVersion = ref<any>('6.0') // 选中的 version
-versionList.value = midjourneyVersionList.value // 默认选择 midjourney
-
-// 定义 Props
-const props = defineProps({})
-
-/**  热词 - click  */
-const handlerHotWordClick = async (hotWord: string) => {
-  // 取消
-  if (selectHotWord.value == hotWord) {
-    selectHotWord.value = ''
-    return
-  }
-  // 选中
-  selectHotWord.value = hotWord
-  // 设置提示次
-  prompt.value = hotWord
-}
-
-/**  size - click  */
-const handlerSizeClick = async (imageSize: ImageSizeVO) => {
-  if (selectImageSize.value === imageSize) {
-    selectImageSize.value = {} as ImageSizeVO
-    return
-  }
-  selectImageSize.value = imageSize
-}
-
-/**  模型 - click  */
-const handlerModelClick = async (model: ImageModelVO) => {
-  selectModel.value = model
-  if (model.key === 'niji') {
-    versionList.value = nijiVersionList.value // 默认选择 niji
-  } else {
-    versionList.value = midjourneyVersionList.value // 默认选择 midjourney
-  }
-  selectVersion.value = versionList.value[0].value
-}
-
-/**  version - click  */
-const handlerChangeVersion = async (version) => {
-  console.log('version', version)
-}
-
-/** 图片生产  */
-const handlerGenerateImage = async () => {
-  // 二次确认
-  await message.confirm(`确认生成内容?`)
-  // todo @范 图片生产逻辑
-  try {
-    // 回调
-    emits('onDrawStart', selectModel.value.key)
-    // 发送请求
-    const req = {
-      prompt: prompt.value,
-      model: selectModel.value.key,
-      width: selectImageSize.value.width,
-      height: selectImageSize.value.height,
-      version: selectVersion.value,
-      base64Array: [],
-    } as ImageMidjourneyImagineReqVO
-    await ImageApi.midjourneyImagine(req)
-  } finally {
-    // 回调
-    emits('onDrawComplete', selectModel.value.key)
-  }
-}
-</script>
-<style scoped lang="scss">
-
-// 提示词
-.prompt {
-}
-
-// 热词
-.hot-words {
-  display: flex;
-  flex-direction: column;
-  margin-top: 30px;
-
-  .word-list {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    justify-content: start;
-    margin-top: 15px;
-
-    .btn {
-      margin: 0;
-    }
-  }
-}
-
-// version
-.version {
-  margin-top: 20px;
-
-  .version-list {
-    margin-top: 20px;
-    width: 100%;
-  }
-}
-
-// 模型
-.model {
-  margin-top: 30px;
-
-  .model-list {
-    margin-top: 15px;
-
-    .modal-item {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      width: 150px;
-      //outline: 1px solid blue;
-      overflow: hidden;
-      border: 3px solid transparent;
-      cursor: pointer;
-
-      .model-font {
-        font-size: 14px;
-        color: #3e3e3e;
-        font-weight: bold;
-      }
-    }
-
-    .selectModel {
-      border: 3px solid #1293ff;
-      border-radius: 5px;
-    }
-  }
-}
-
-
-// 尺寸
-.image-size {
-  width: 100%;
-  margin-top: 30px;
-
-  .size-list {
-    display: flex;
-    flex-direction: row;
-    justify-content: space-between;
-    width: 100%;
-    margin-top: 20px;
-
-    .size-item {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      cursor: pointer;
-
-      .size-wrapper {
-        display: flex;
-        flex-direction: column;
-        align-items: center;
-        justify-content: center;
-        border-radius: 7px;
-        padding: 4px;
-        width: 50px;
-        height: 50px;
-        background-color: #fff;
-        border: 1px solid #fff;
-      }
-
-      .size-font {
-        font-size: 14px;
-        color: #3e3e3e;
-        font-weight: bold;
-      }
-    }
-  }
-
-  .selectImageSize {
-    border: 1px solid #1293ff !important;
-  }
-}
-
-.btns {
-  display: flex;
-  justify-content: center;
-  margin-top: 50px;
-}
-</style>

+ 104 - 0
src/views/ai/image/square/index.vue

@@ -0,0 +1,104 @@
+<template>
+  <div class="square-container">
+    <!-- TODO @fan:style 建议换成 unocss -->
+    <!-- TODO @fan:Search 可以换成 Icon 组件么? -->
+    <el-input
+      v-model="queryParams.prompt"
+      style="width: 100%; margin-bottom: 20px"
+      size="large"
+      placeholder="请输入要搜索的内容"
+      :suffix-icon="Search"
+      @keyup.enter="handleQuery"
+    />
+    <div class="gallery">
+      <!-- TODO @fan:这个图片的风格,要不和 ImageCard.vue 界面一致?(只有卡片,没有操作);因为看着更有相框的感觉~~~ -->
+      <div v-for="item in list" :key="item.id" class="gallery-item">
+        <img :src="item.picUrl" class="img" />
+      </div>
+    </div>
+    <!-- TODO @fan:缺少翻页 -->
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </div>
+</template>
+<script setup lang="ts">
+import { ImageApi, ImageVO } from '@/api/ai/image'
+import { Search } from '@element-plus/icons-vue'
+
+// TODO @fan:加个 loading 加载中的状态
+const loading = ref(true) // 列表的加载中
+const list = ref<ImageVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  publicStatus: true,
+  prompt: undefined
+})
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await ImageApi.getImagePageMy(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 初始化 */
+onMounted(async () => {
+  await getList()
+})
+</script>
+<style scoped lang="scss">
+.square-container {
+  background-color: #fff;
+  padding: 20px;
+
+  .gallery {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 10px;
+    //max-width: 1000px;
+    background-color: #fff;
+    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+  }
+
+  .gallery-item {
+    position: relative;
+    overflow: hidden;
+    background: #f0f0f0;
+    cursor: pointer;
+    transition: transform 0.3s;
+  }
+
+  .gallery-item img {
+    width: 100%;
+    height: auto;
+    display: block;
+    transition: transform 0.3s;
+  }
+
+  .gallery-item:hover img {
+    transform: scale(1.1);
+  }
+
+  .gallery-item:hover {
+    transform: scale(1.05);
+  }
+}
+</style>

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

@@ -1,297 +0,0 @@
-<!-- dall3 -->
-<template>
-  <div class="prompt">
-    <el-text tag="b">画面描述</el-text>
-    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
-    <!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
-    <el-input
-      v-model="prompt"
-      maxlength="1024"
-      rows="5"
-      style="width: 100%; margin-top: 15px;"
-      input-style="border-radius: 7px;"
-      placeholder="例如:童话里的小屋应该是什么样子?"
-      show-word-limit
-      type="textarea"
-    />
-  </div>
-  <div class="hot-words">
-    <div>
-      <el-text tag="b">随机热词</el-text>
-    </div>
-    <el-space wrap class="word-list">
-      <el-button round
-                 class="btn"
-                 :type="(selectHotWord === hotWord ? 'primary' : 'default')"
-                 v-for="hotWord in hotWords"
-                 :key="hotWord"
-                 @click="handlerHotWordClick(hotWord)"
-      >
-        {{ hotWord }}
-      </el-button>
-    </el-space>
-  </div>
-  <div class="group-item">
-    <div>
-      <el-text tag="b">采样</el-text>
-    </div>
-    <el-space wrap class="group-item-body">
-      <el-select
-        v-model="selectSampler"
-        placeholder="Select"
-        size="large"
-        style="width: 350px;"
-      >
-        <el-option
-          v-for="item in sampler"
-          :key="item.key"
-          :label="item.name"
-          :value="item.key"
-        />
-      </el-select>
-    </el-space>
-  </div>
-
-  <div class="group-item">
-    <div>
-      <el-text tag="b">图片尺寸</el-text>
-    </div>
-    <el-space wrap class="group-item-body">
-      <el-select
-        v-model="selectImageSize"
-        placeholder="Select"
-        size="large"
-        style="width: 350px;"
-      >
-        <el-option
-          v-for="item in imageSizeList"
-          :key="item.key"
-          :label="item.key"
-          :value="item.key"
-        />
-      </el-select>
-    </el-space>
-  </div>
-  <div class="group-item">
-    <div>
-      <el-text tag="b">迭代步数</el-text>
-    </div>
-    <el-space wrap class="group-item-body">
-      <el-input v-model="steps" type="number" size="large" style="width: 350px" placeholder="Please input" />
-    </el-space>
-  </div>
-  <div class="group-item">
-    <div>
-      <el-text tag="b">随机性</el-text>
-    </div>
-    <el-space wrap class="group-item-body">
-      <el-input v-model="seed" type="number" size="large" style="width: 350px" placeholder="Please input" />
-    </el-space>
-  </div>
-  <div class="btns">
-    <el-button type="primary"
-               size="large"
-               round
-               :loading="drawIn"
-               @click="handlerGenerateImage">
-      {{drawIn ? '生成中' : '生成内容'}}
-    </el-button>
-  </div>
-</template>
-<script setup lang="ts">
-import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
-
-// image 模型
-interface ImageModelVO {
-  key: string
-  name: string
-}
-
-// image 大小
-interface ImageSizeVO {
-  key: string
-  width: string,
-  height: string,
-}
-
-// 定义属性
-const prompt = ref<string>('')  // 提示词
-const drawIn = ref<boolean>(false)  // 生成中
-const selectHotWord = ref<string>('') // 选中的热词
-const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
-const selectSampler = ref<any>({}) // 模型
-// message
-const message = useMessage()
-// 模型
-const sampler = ref<ImageModelVO[]>([
-  {
-    key: 'Euler a',
-    name: 'Euler a',
-  },
-  {
-    key: 'DPM++ 2S a Karras',
-    name: 'DPM++ 2S a Karras',
-  },
-  {
-    key: 'DPM++ 2M Karras',
-    name: 'DPM++ 2M Karras',
-  },
-  {
-    key: 'DPM++ SDE Karras',
-    name: 'DPM++ SDE Karras',
-  },
-  {
-    key: 'DPM++ 2M SDE Karras',
-    name: 'DPM++ 2M SDE Karras',
-  },
-])  // 模型
-selectSampler.value = sampler.value[0]
-
-
-const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
-const imageSizeList = ref<ImageSizeVO[]>([
-  {
-    key: '512x512',
-    width: '512',
-    height: '512',
-  },
-  {
-    key: '1024x1024',
-    width: '1024',
-    height: '1024',
-  },
-  {
-    key: '1024x1792',
-    width: '1024',
-    height: '1792',
-  },
-  {
-    key: '1792x1024',
-    width: '1792',
-    height: '1024',
-  },
-  {
-    key: '2048x2048',
-    width: '2048',
-    height: '2048',
-  },
-  {
-    key: '720x1280',
-    width: '720',
-    height: '1280',
-  },
-  {
-    key: '1080x1920',
-    width: '1080',
-    height: '1920',
-  },
-  {
-    key: '1440x2560',
-    width: '1440',
-    height: '2560',
-  },
-  {
-    key: '2160x3840',
-    width: '2160',
-    height: '3840',
-  },
-]) // size
-selectImageSize.value = imageSizeList.value[0]
-
-const steps = ref<number>(20) // 迭代步数
-const seed = ref<number>(-1) // 控制生成图像的随机性
-
-// 定义 Props
-const props = defineProps({})
-// 定义 emits
-const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
-
-// TODO @fan:如果是简单注释,建议用 /** */,主要是现在项目里是这种风格哈,保持一致好点~
-// TODO @fan:handler 应该改成 handle 哈
-/** 热词 - click  */
-const handlerHotWordClick = async (hotWord: string) => {
-  // 取消选中
-  if (selectHotWord.value == hotWord) {
-    selectHotWord.value = ''
-    return
-  }
-  // 选中
-  selectHotWord.value = hotWord
-  // 替换提示词
-  prompt.value = hotWord
-}
-
-/**  图片生产  */
-const handlerGenerateImage = async () => {
-  // 二次确认
-  await message.confirm(`确认生成内容?`)
-  try {
-    // 加载中
-    drawIn.value = true
-    // 回调
-    emits('onDrawStart', 'StableDiffusion')
-    const form = {
-      platform: 'StableDiffusion',
-      model: 'stable-diffusion-v1-6',
-      prompt: prompt.value, // 提示词
-      width: selectImageSize.value.width, // 图片宽度
-      height: selectImageSize.value.height, // 图片高度
-      options: {
-        sampler: selectSampler.value.key, // 采样算法
-        seed: seed.value, // 随机种子
-        steps: steps.value, // 图片生成步数
-      },
-    } as ImageDrawReqVO
-    // 发送请求
-    await ImageApi.drawImage(form)
-  } finally {
-    // 回调
-    emits('onDrawComplete', 'StableDiffusion')
-    // 加载结束
-    drawIn.value = false
-  }
-}
-
-</script>
-<style scoped lang="scss">
-
-// 提示词
-.prompt {
-}
-
-// 热词
-.hot-words {
-  display: flex;
-  flex-direction: column;
-  margin-top: 30px;
-
-  .word-list {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    justify-content: start;
-    margin-top: 15px;
-
-    .btn {
-      margin: 0;
-    }
-  }
-}
-
-// 模型
-.group-item {
-  margin-top: 30px;
-
-  .group-item-body {
-    margin-top: 15px;
-    width: 100%;
-  }
-}
-
-
-.btns {
-  display: flex;
-  justify-content: center;
-  margin-top: 50px;
-}
-</style>

+ 78 - 0
src/views/ai/mindmap/index/components/Left.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="w-[350px] p-5 flex flex-col bg-[#f5f7f9]">
+    <h3 class="w-full h-full h-7 text-5 text-center leading-[28px] title">思维导图创作中心</h3>
+    <!--下面表单部分-->
+    <div class="flex-grow overflow-y-auto">
+      <div class="mt-[30ppx]">
+        <el-text tag="b">您的需求?</el-text>
+        <el-input
+          v-model="formData.prompt"
+          maxlength="1024"
+          rows="5"
+          class="w-100% mt-15px"
+          input-style="border-radius: 7px;"
+          placeholder="请输入提示词,让AI帮你完善"
+          show-word-limit
+          type="textarea"
+        />
+        <el-button
+          class="!w-full mt-[15px]"
+          type="primary"
+          :loading="isGenerating"
+          @click="emits('submit', formData)"
+        >
+          智能生成思维导图
+        </el-button>
+      </div>
+      <div class="mt-[30px]">
+        <el-text tag="b">使用已有内容生成?</el-text>
+        <el-input
+          v-model="generatedContent"
+          maxlength="1024"
+          rows="5"
+          class="w-100% mt-15px"
+          input-style="border-radius: 7px;"
+          placeholder="例如:童话里的小屋应该是什么样子?"
+          show-word-limit
+          type="textarea"
+        />
+        <el-button
+          class="!w-full mt-[15px]"
+          type="primary"
+          @click="emits('directGenerate', generatedContent)"
+          :disabled="isGenerating"
+        >
+          直接生成
+        </el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MindMapContentExample } from '@/views/ai/utils/constants'
+
+const emits = defineEmits(['submit', 'directGenerate'])
+defineProps<{
+  isGenerating: boolean
+}>()
+// 提交的提示词字段
+const formData = reactive({
+  prompt: ''
+})
+
+const generatedContent = ref(MindMapContentExample) // 已有的内容
+
+defineExpose({
+  setGeneratedContent(newContent: string) {
+    // 设置已有的内容,在生成结束的时候将结果赋值给该值
+    generatedContent.value = newContent
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.title {
+  color: var(--el-color-primary);
+}
+</style>

+ 163 - 0
src/views/ai/mindmap/index/components/Right.vue

@@ -0,0 +1,163 @@
+<template>
+  <el-card class="my-card h-full flex-grow">
+    <template #header>
+      <h3 class="m-0 px-7 shrink-0 flex items-center justify-between">
+        <span>思维导图预览</span>
+        <!-- 展示在右上角 -->
+        <el-button type="primary" v-show="isEnd" @click="downloadImage" size="small">
+          <template #icon>
+            <Icon icon="ph:copy-bold" />
+          </template>
+          下载图片
+        </el-button>
+      </h3>
+    </template>
+
+    <div ref="contentRef" class="hide-scroll-bar h-full box-border">
+      <!--展示 markdown 的容器,最终生成的是 html 字符串,直接用 v-html 嵌入-->
+      <div v-if="isGenerating" ref="mdContainerRef" class="wh-full overflow-y-auto">
+        <div class="flex flex-col items-center justify-center" v-html="html"></div>
+      </div>
+
+      <div ref="mindMapRef" class="wh-full">
+        <svg ref="svgRef" class="w-full" :style="{ height: `${contentAreaHeight}px` }" />
+        <div ref="toolBarRef" class="absolute bottom-[10px] right-5"></div>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { Markmap } from 'markmap-view'
+import { Transformer } from 'markmap-lib'
+import { Toolbar } from 'markmap-toolbar'
+import markdownit from 'markdown-it'
+import download from '@/utils/download'
+
+const md = markdownit()
+const message = useMessage() // 消息弹窗
+
+const props = defineProps<{
+  generatedContent: string // 生成结果
+  isEnd: boolean // 是否结束
+  isGenerating: boolean // 是否正在生成
+  isStart: boolean // 开始状态,开始时需要清除 html
+}>()
+const contentRef = ref<HTMLDivElement>() // 右侧出来 header 以下的区域
+const mdContainerRef = ref<HTMLDivElement>() // markdown 的容器,用来滚动到底下的
+const mindMapRef = ref<HTMLDivElement>() // 思维导图的容器
+const svgRef = ref<SVGElement>() // 思维导图的渲染 svg
+const toolBarRef = ref<HTMLDivElement>() // 思维导图右下角的工具栏,缩放等
+const html = ref('') // 生成过程中的文本
+const contentAreaHeight = ref(0) // 生成区域的高度,出去 header 部分
+let markMap: Markmap | null = null
+const transformer = new Transformer()
+
+onMounted(() => {
+  contentAreaHeight.value = contentRef.value?.clientHeight || 0 // 获取区域高度
+  /** 初始化思维导图 **/
+  try {
+    markMap = Markmap.create(svgRef.value!)
+    const { el } = Toolbar.create(markMap)
+    toolBarRef.value?.append(el)
+    nextTick(update)
+  } catch (e) {
+    message.error('思维导图初始化失败')
+  }
+})
+
+watch(props, ({ generatedContent, isGenerating, isEnd, isStart }) => {
+  // 开始生成的时候清空一下 markdown 的内容
+  if (isStart) {
+    html.value = ''
+  }
+  // 生成内容的时候使用 markdown 来渲染
+  if (isGenerating) {
+    html.value = md.render(generatedContent)
+  }
+  // 生成结束时更新思维导图
+  if (isEnd) {
+    update()
+  }
+})
+
+/** 更新思维导图的展示 */
+const update = () => {
+  try {
+    const { root } = transformer.transform(processContent(props.generatedContent))
+    markMap?.setData(root)
+    markMap?.fit()
+  } catch (e) {
+    console.error(e)
+  }
+}
+
+/** 处理内容 */
+const processContent = (text: string) => {
+  const arr: string[] = []
+  const lines = text.split('\n')
+  for (let line of lines) {
+    if (line.indexOf('```') !== -1) {
+      continue
+    }
+    line = line.replace(/([*_~`>])|(\d+\.)\s/g, '')
+    arr.push(line)
+  }
+  return arr.join('\n')
+}
+
+/** 下载图片:download SVG to png file */
+const downloadImage = () => {
+  const svgElement = mindMapRef.value
+  // 将 SVG 渲染到图片对象
+  const serializer = new XMLSerializer()
+  const source = `<?xml version="1.0" standalone="no"?>\r\n${serializer.serializeToString(svgRef.value!)}`
+  const base64Url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`
+  download.image({
+    url: base64Url,
+    canvasWidth: svgElement?.offsetWidth,
+    canvasHeight: svgElement?.offsetHeight,
+    drawWithImageSize: false
+  })
+}
+
+defineExpose({
+  scrollBottom() {
+    mdContainerRef.value?.scrollTo(0, mdContainerRef.value?.scrollHeight)
+  }
+})
+</script>
+<style lang="scss" scoped>
+.hide-scroll-bar {
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+
+  &::-webkit-scrollbar {
+    width: 0;
+    height: 0;
+  }
+}
+.my-card {
+  display: flex;
+  flex-direction: column;
+
+  :deep(.el-card__body) {
+    box-sizing: border-box;
+    flex-grow: 1;
+    overflow-y: auto;
+    padding: 0;
+    @extend .hide-scroll-bar;
+  }
+}
+// markmap的tool样式覆盖
+:deep(.markmap) {
+  width: 100%;
+}
+:deep(.mm-toolbar-brand) {
+  display: none;
+}
+:deep(.mm-toolbar) {
+  display: flex;
+  flex-direction: row;
+}
+</style>

+ 92 - 0
src/views/ai/mindmap/index/index.vue

@@ -0,0 +1,92 @@
+<template>
+  <div class="absolute top-0 left-0 right-0 bottom-0 flex">
+    <!--表单区域-->
+    <Left
+      ref="leftRef"
+      @submit="submit"
+      @direct-generate="directGenerate"
+      :is-generating="isGenerating"
+    />
+    <!--右边生成思维导图区域-->
+    <Right
+      ref="rightRef"
+      :generatedContent="generatedContent"
+      :isEnd="isEnd"
+      :isGenerating="isGenerating"
+      :isStart="isStart"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import Left from './components/Left.vue'
+import Right from './components/Right.vue'
+import { AiMindMapApi, AiMindMapGenerateReqVO } from '@/api/ai/mindmap'
+import { MindMapContentExample } from '@/views/ai/utils/constants'
+
+defineOptions({
+  name: 'AiMindMap'
+})
+const ctrl = ref<AbortController>() // 请求控制
+const isGenerating = ref(false) // 是否正在生成思维导图
+const isStart = ref(false) // 开始生成,用来清空思维导图
+const isEnd = ref(true) // 用来判断结束的时候渲染思维导图
+const message = useMessage() // 消息提示
+
+const generatedContent = ref('') // 生成思维导图结果
+
+const leftRef = ref<InstanceType<typeof Left>>() // 左边组件
+const rightRef = ref<InstanceType<typeof Right>>() // 右边组件
+
+/** 使用已有内容直接生成 **/
+const directGenerate = (existPrompt: string) => {
+  isEnd.value = false // 先设置为 false 再设置为 true,让子组建的 watch 能够监听到
+  generatedContent.value = existPrompt
+  isEnd.value = true
+}
+
+/** 停止 stream 生成 */
+const stopStream = () => {
+  isGenerating.value = false
+  isStart.value = false
+  ctrl.value?.abort()
+}
+
+/** 提交生成 */
+const submit = (data: AiMindMapGenerateReqVO) => {
+  isGenerating.value = true
+  isStart.value = true
+  isEnd.value = false
+  ctrl.value = new AbortController() // 请求控制赋值
+  generatedContent.value = '' // 清空生成数据
+  AiMindMapApi.generateMindMap({
+    data,
+    onMessage: async (res) => {
+      const { code, data, msg } = JSON.parse(res.data)
+      if (code !== 0) {
+        message.alert(`生成思维导图异常! ${msg}`)
+        stopStream()
+        return
+      }
+      generatedContent.value = generatedContent.value + data
+      await nextTick()
+      rightRef.value?.scrollBottom()
+    },
+    onClose() {
+      isEnd.value = true
+      leftRef.value?.setGeneratedContent(generatedContent.value)
+      stopStream()
+    },
+    onError(err) {
+      console.error('生成思维导图失败', err)
+      stopStream()
+    },
+    ctrl: ctrl.value
+  })
+}
+
+/** 初始化 */
+onMounted(() => {
+  generatedContent.value = MindMapContentExample
+})
+</script>

+ 189 - 0
src/views/ai/mindmap/manager/index.vue

@@ -0,0 +1,189 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-select
+          v-model="queryParams.userId"
+          clearable
+          placeholder="请输入用户编号"
+          class="!w-240px"
+        >
+          <el-option
+            v-for="item in userList"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="提示词" prop="prompt">
+        <el-input
+          v-model="queryParams.prompt"
+          placeholder="请输入提示词"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="提示词" align="center" prop="prompt" width="180" />
+      <el-table-column label="思维导图" align="center" prop="generatedContent" min-width="300" />
+      <el-table-column label="模型" align="center" prop="model" width="180" />
+      <el-table-column
+        label="创建时间"
+        align="center"
+        prop="createTime"
+        :formatter="dateFormatter"
+        width="180px"
+      />
+      <el-table-column label="错误信息" align="center" prop="errorMessage" />
+      <el-table-column label="操作" align="center" width="120" fixed="right">
+        <template #default="scope">
+          <el-button link type="primary" @click="openPreview(scope.row)"> 预览 </el-button>
+          <el-button
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+            v-hasPermi="['ai:mind-map:delete']"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      :total="total"
+      v-model:page="queryParams.pageNo"
+      v-model:limit="queryParams.pageSize"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 思维导图的预览 -->
+  <el-drawer v-model="previewVisible" :with-header="false" size="800px">
+    <Right
+      v-if="previewVisible2"
+      :generatedContent="previewContent"
+      :isEnd="true"
+      :isGenerating="false"
+      :isStart="false"
+    />
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import { AiMindMapApi, MindMapVO } from '@/api/ai/mindmap'
+import * as UserApi from '@/api/system/user'
+import Right from '@/views/ai/mindmap/index/components/Right.vue'
+
+/** AI 思维导图 列表 */
+defineOptions({ name: 'AiMindMapManager' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<MindMapVO[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  userId: undefined,
+  prompt: undefined,
+  createTime: []
+})
+const queryFormRef = ref() // 搜索的表单
+const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await AiMindMapApi.getMindMapPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await AiMindMapApi.deleteMindMap(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 预览操作按钮 */
+const previewVisible = ref(false) // drawer 的显示隐藏
+const previewVisible2 = ref(false) // right 的显示隐藏
+const previewContent = ref('')
+const openPreview = async (row: MindMapVO) => {
+  previewVisible2.value = false
+  previewVisible.value = true
+  // 在 drawer 渲染完后,再渲染 right 预览,不然会报错,需要保证 width 宽度先出来
+  await nextTick()
+  previewVisible2.value = true
+  previewContent.value = row.generatedContent
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  getList()
+  // 获得用户列表
+  userList.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 18 - 25
src/views/ai/model/chatRole/ChatRoleForm.vue

@@ -13,7 +13,7 @@
       <el-form-item label="角色头像" prop="avatar">
         <UploadImg v-model="formData.avatar" height="60px" width="60px" />
       </el-form-item>
-      <el-form-item label="绑定模型" prop="modelId" v-if="!isUser(formType)">
+      <el-form-item label="绑定模型" prop="modelId" v-if="!isUser">
         <el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
           <el-option
             v-for="chatModel in chatModelList"
@@ -23,7 +23,7 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="角色类别" prop="category" v-if="!isUser(formType)">
+      <el-form-item label="角色类别" prop="category" v-if="!isUser">
         <el-input v-model="formData.category" placeholder="请输入角色类别" />
       </el-form-item>
       <el-form-item label="角色描述" prop="description">
@@ -32,7 +32,7 @@
       <el-form-item label="角色设定" prop="systemMessage">
         <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
       </el-form-item>
-      <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser(formType)">
+      <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
         <el-radio-group v-model="formData.publicStatus">
           <el-radio
             v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
@@ -43,10 +43,10 @@
           </el-radio>
         </el-radio-group>
       </el-form-item>
-      <el-form-item label="角色排序" prop="sort" v-if="!isUser(formType)">
+      <el-form-item label="角色排序" prop="sort" v-if="!isUser">
         <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
       </el-form-item>
-      <el-form-item label="开启状态" prop="status" v-if="!isUser(formType)">
+      <el-form-item label="开启状态" prop="status" v-if="!isUser">
         <el-radio-group v-model="formData.status">
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -69,6 +69,7 @@ import { getIntDictOptions, getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
 import { ChatRoleApi, ChatRoleVO } from '@/api/ai/model/chatRole'
 import { CommonStatusEnum } from '@/utils/constants'
 import { ChatModelApi, ChatModelVO } from '@/api/ai/model/chatModel'
+import {FormRules} from "element-plus";
 
 /** AI 聊天角色 表单 */
 defineOptions({ name: 'ChatRoleForm' })
@@ -92,30 +93,23 @@ const formData = ref({
   publicStatus: true,
   status: CommonStatusEnum.ENABLE
 })
-const formRules = ref() // reactive(formRulesObj)
 const formRef = ref() // 表单 Ref
 const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
 
 /** 是否【我】自己创建,私有角色 */
-// TODO @fan:建议改成计算函数 computed
-const isUser = (type: string) => {
-  return type === 'my-create' || type === 'my-update'
-}
-
-// TODO @fan:直接使用 formRules;只要隐藏掉的字段,它是不会校验的哈;
-const getFormRules = async (type: string) => {
-  let formRulesObj = {
-    name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
-    avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
-    category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
-    sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
-    description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
-    systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
-    publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
-  }
+const isUser = computed(() => {
+  return formType.value === 'my-create' || formType.value === 'my-update'
+})
 
-  formRules.value = reactive(formRulesObj)
-}
+const formRules = reactive<FormRules>({
+  name: [{ required: true, message: '角色名称不能为空', trigger: 'blur' }],
+  avatar: [{ required: true, message: '角色头像不能为空', trigger: 'blur' }],
+  category: [{ required: true, message: '角色类别不能为空', trigger: 'blur' }],
+  sort: [{ required: true, message: '角色排序不能为空', trigger: 'blur' }],
+  description: [{ required: true, message: '角色描述不能为空', trigger: 'blur' }],
+  systemMessage: [{ required: true, message: '角色设定不能为空', trigger: 'blur' }],
+  publicStatus: [{ required: true, message: '是否公开不能为空', trigger: 'blur' }]
+})
 
 /** 打开弹窗 */
 // TODO @fan:title 是不是收敛到 type 判断生成 title,会更合理
@@ -123,7 +117,6 @@ const open = async (type: string, id?: number, title?: string) => {
   dialogVisible.value = true
   dialogTitle.value = title || t('action.' + type)
   formType.value = type
-  getFormRules(type)
   resetForm()
   // 修改时,设置数据
   if (id) {

+ 26 - 0
src/views/ai/music/index/index.vue

@@ -0,0 +1,26 @@
+<template>
+<div class="flex h-full items-stretch">
+    <!-- 模式 -->
+    <Mode class="flex-none" @generate-music="generateMusic"/>
+    <!-- 音频列表 -->
+    <List ref="listRef" class="flex-auto"/>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Mode from './mode/index.vue'
+import List from './list/index.vue'
+
+defineOptions({ name: 'Index' })
+
+const listRef = ref<Nullable<{generateMusic: (...args) => void}>>(null)
+
+/*
+ *@Description: 拿到左侧配置信息调用右侧音乐生成的方法
+ *@MethodAuthor: xiaohong
+ *@Date: 2024-07-19 11:13:38
+*/
+function generateMusic (args: {formData: Recordable}) {
+ unref(listRef)?.generateMusic(args.formData)
+}
+</script>

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

@@ -0,0 +1,70 @@
+<template>
+  <div class="flex items-center justify-between px-2 h-72px bg-[var(--el-bg-color-overlay)] b-solid b-1 b-[var(--el-border-color)] b-l-none">
+    <!-- 歌曲信息 -->
+    <div class="flex gap-[10px]">
+      <el-image src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png" class="w-[45px]"/>
+      <div>
+        <div>{{currentSong.name}}</div>
+        <div class="text-[12px] text-gray-400">{{currentSong.singer}}</div>
+      </div>
+    </div>
+      
+    <!-- 音频controls -->
+    <div class="flex gap-[12px] items-center">
+      <Icon icon="majesticons:back-circle" :size="20" class="text-gray-300 cursor-pointer"/>
+      <Icon :icon="audioProps.paused ? 'mdi:arrow-right-drop-circle' : 'solar:pause-circle-bold'" :size="30" class=" cursor-pointer" @click="toggleStatus('paused')"/>
+      <Icon icon="majesticons:next-circle" :size="20" class="text-gray-300 cursor-pointer"/>
+      <div class="flex gap-[16px] items-center">
+        <span>{{audioProps.currentTime}}</span>
+        <el-slider v-model="audioProps.duration" color="#409eff" class="w-[160px!important] "/>
+        <span>{{ audioProps.duration }}</span>
+      </div>
+      <!-- 音频 -->
+      <audio v-bind="audioProps" ref="audioRef" controls v-show="!audioProps" @timeupdate="audioTimeUpdate">
+        <source :src="audioUrl"/>
+      </audio>
+    </div>
+
+    <!-- 音量控制器 -->
+    <div class="flex gap-[16px] items-center">
+      <Icon :icon="audioProps.muted ? 'tabler:volume-off' : 'tabler:volume'" :size="20" class="cursor-pointer" @click="toggleStatus('muted')"/>
+      <el-slider v-model="audioProps.volume" color="#409eff" class="w-[160px!important] "/>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { formatPast } from '@/utils/formatTime'
+import audioUrl from '@/assets/audio/response.mp3'
+
+defineOptions({ name: 'Index' })
+
+const currentSong = inject('currentSong', {})
+
+const audioRef = ref<Nullable<HTMLElement>>(null)
+  // 音频相关属性https://www.runoob.com/tags/ref-av-dom.html
+const audioProps = reactive({
+  autoplay: true,
+  paused: false,
+  currentTime: '00:00',
+  duration: '00:00',
+  muted:  false,
+  volume: 50,
+})
+
+function toggleStatus (type: string) {
+  audioProps[type] = !audioProps[type]
+  if (type === 'paused' && audioRef.value) {
+    if (audioProps[type]) {
+      audioRef.value.pause()
+    } else {
+      audioRef.value.play()
+    }
+  }
+}
+
+// 更新播放位置
+function audioTimeUpdate (args) {
+  audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss')
+}
+</script>

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

@@ -0,0 +1,108 @@
+<template>
+  <div class="flex flex-col">
+    <div class="flex-auto flex overflow-hidden">
+      <el-tabs v-model="currentType" class="flex-auto px-[var(--app-content-padding)]">
+        <!-- 我的创作 -->
+        <el-tab-pane v-loading="loading" label="我的创作" name="mine">
+          <el-row v-if="mySongList.length" :gutter="12">
+            <el-col v-for="song in mySongList" :key="song.id" :span="24">
+              <songCard :songInfo="song" @play="setCurrentSong(song)"/>
+            </el-col>
+          </el-row>
+          <el-empty v-else description="暂无音乐"/>
+        </el-tab-pane>
+
+        <!-- 试听广场 -->
+        <el-tab-pane v-loading="loading" label="试听广场" name="square">
+          <el-row v-if="squareSongList.length" v-loading="loading" :gutter="12">
+            <el-col v-for="song in squareSongList" :key="song.id" :span="24">
+              <songCard :songInfo="song" @play="setCurrentSong(song)"/>
+            </el-col>
+          </el-row>
+          <el-empty v-else description="暂无音乐"/>
+        </el-tab-pane>
+      </el-tabs>
+      <!-- songInfo -->
+      <songInfo class="flex-none"/>
+    </div>
+    <audioBar class="flex-none"/>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import songCard from './songCard/index.vue'
+import songInfo from './songInfo/index.vue'
+import audioBar from './audioBar/index.vue'
+
+defineOptions({ name: 'Index' })
+
+
+const currentType = ref('mine')
+// loading 状态
+const loading = ref(false)
+// 当前音乐
+const currentSong = ref({})
+
+const mySongList = ref<Recordable[]>([])
+const squareSongList = ref<Recordable[]>([])
+
+provide('currentSong', currentSong)
+
+/*
+ *@Description: 调接口生成音乐列表
+ *@MethodAuthor: xiaohong
+ *@Date: 2024-06-27 17:06:44
+*/
+function generateMusic (formData: Recordable) {
+  console.log(formData);
+  loading.value = true
+  setTimeout(() => {
+    mySongList.value = Array.from({ length: 20 }, (_, index) => {
+      return {
+        id: index,
+        audioUrl: '',
+        videoUrl: '',
+        title: '我走后' + index,
+        imageUrl: 'https://www.carsmp3.com/data/attachment/forum/201909/19/091020q5kgre20fidreqyt.jpg',
+        desc: 'Metal, symphony, film soundtrack, grand, majesticMetal, dtrack, grand, majestic',
+        date: '2024年04月30日 14:02:57',
+        lyric: `<div class="_words_17xen_66"><div>大江东去,浪淘尽,千古风流人物。
+          </div><div>故垒西边,人道是,三国周郎赤壁。
+          </div><div>乱石穿空,惊涛拍岸,卷起千堆雪。
+          </div><div>江山如画,一时多少豪杰。
+          </div><div>
+          </div><div>遥想公瑾当年,小乔初嫁了,雄姿英发。
+          </div><div>羽扇纶巾,谈笑间,樯橹灰飞烟灭。
+          </div><div>故国神游,多情应笑我,早生华发。
+          </div><div>人生如梦,一尊还酹江月。</div></div>`
+      }
+    })
+    loading.value = false
+  }, 3000)
+}
+
+/*
+ *@Description: 设置当前播放的音乐
+ *@MethodAuthor: xiaohong
+ *@Date: 2024-07-19 11:22:33
+*/
+function setCurrentSong (music: Recordable) {
+  currentSong.value = music
+}
+
+defineExpose({
+  generateMusic
+})
+</script>
+
+
+<style lang="scss" scoped>
+:deep(.el-tabs) {
+  display: flex;
+  flex-direction: column;
+  .el-tabs__content {
+    padding: 0 7px;
+    overflow: auto;
+  }
+}
+</style>

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

@@ -0,0 +1,36 @@
+<template>
+  <div class="flex bg-[var(--el-bg-color-overlay)] p-12px mb-12px rounded-1">
+    <div class="relative" @click="playSong">
+      <el-image :src="songInfo.imageUrl" class="flex-none w-80px"/>
+      <div class="bg-black bg-op-40 absolute top-0 left-0 w-full h-full flex items-center justify-center cursor-pointer">
+        <Icon :icon="currentSong.id === songInfo.id ?  'solar:pause-circle-bold':'mdi:arrow-right-drop-circle'" :size="30" />
+      </div>
+    </div>
+    <div class="ml-8px">
+      <div>{{ songInfo.title }}</div>
+      <div class="mt-8px text-12px text-[var(--el-text-color-secondary)] line-clamp-2">
+        {{ songInfo.desc }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+
+defineOptions({ name: 'Index' })
+
+defineProps({
+  songInfo: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const emits = defineEmits(['play'])
+
+const currentSong = inject('currentSong', {})
+
+function playSong () {
+  emits('play')
+}
+</script>

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

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

+ 55 - 0
src/views/ai/music/index/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>

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

@@ -0,0 +1,41 @@
+<template>
+  <ContentWrap class="w-300px h-full mb-[0!important]">
+    <el-radio-group v-model="generateMode" class="mb-15px">
+      <el-radio-button label="desc">
+        描述模式
+      </el-radio-button>
+      <el-radio-button label="lyric">
+        歌词模式
+      </el-radio-button>
+    </el-radio-group>
+
+    <!-- 描述模式/歌词模式 切换 -->
+    <component :is="generateMode === 'desc' ? desc : lyric" ref="modeRef"/>
+
+    <el-button type="primary" round class="w-full" @click="generateMusic">
+      创作音乐
+    </el-button>
+  </ContentWrap>
+</template>
+
+<script lang="ts" setup>
+import desc from './desc.vue'
+import lyric from './lyric.vue'
+
+defineOptions({ name: 'Index' })
+
+const emits = defineEmits(['generate-music'])
+
+const generateMode = ref('lyric')
+
+const modeRef = ref<Nullable<{ formData: Recordable }>>(null)
+
+/*
+ *@Description: 根据信息生成音乐
+ *@MethodAuthor: xiaohong
+ *@Date: 2024-06-27 16:40:16
+*/
+function generateMusic () {
+  emits('generate-music', {formData: unref(modeRef)?.formData})
+}
+</script>

+ 83 - 0
src/views/ai/music/index/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/index/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>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 409 - 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)
+}

+ 213 - 0
src/views/ai/write/index/components/Left.vue

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

+ 120 - 0
src/views/ai/write/index/components/Right.vue

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

+ 32 - 0
src/views/ai/write/index/components/Tag.vue

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

+ 76 - 0
src/views/ai/write/index/index.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="absolute top-0 left-0 right-0 bottom-0 flex">
+    <Left
+      :is-writing="isWriting"
+      class="h-full"
+      @submit="submit"
+      @reset="reset"
+      @example="handleExampleClick"
+    />
+    <Right
+      :is-writing="isWriting"
+      @stop-stream="stopStream"
+      ref="rightRef"
+      class="flex-grow"
+      v-model:content="writeResult"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import Left from './components/Left.vue'
+import Right from './components/Right.vue'
+import { WriteApi, WriteVO } from '@/api/ai/write'
+import { WriteExample } from '@/views/ai/utils/constants'
+
+const message = useMessage()
+
+const writeResult = ref('') // 写作结果
+const isWriting = ref(false) // 是否正在写作中
+const abortController = ref<AbortController>() // // 写作进行中 abort 控制器(控制 stream 写作)
+
+/** 停止 stream 生成 */
+const stopStream = () => {
+  abortController.value?.abort()
+  isWriting.value = false
+}
+
+/** 执行写作 */
+const rightRef = ref<InstanceType<typeof Right>>()
+const submit = (data: WriteVO) => {
+  abortController.value = new AbortController()
+  writeResult.value = ''
+  isWriting.value = true
+  WriteApi.writeStream({
+    data,
+    onMessage: async (res) => {
+      const { code, data, msg } = JSON.parse(res.data)
+      if (code !== 0) {
+        message.alert(`写作异常! ${msg}`)
+        stopStream()
+        return
+      }
+      writeResult.value = writeResult.value + data
+      // 滚动到底部
+      await nextTick()
+      rightRef.value?.scrollToBottom()
+    },
+    ctrl: abortController.value,
+    onClose: stopStream,
+    onError: (...err) => {
+      console.error('写作异常', ...err)
+      stopStream()
+    }
+  })
+}
+
+/** 点击示例触发 */
+const handleExampleClick = (type: keyof typeof WriteExample) => {
+  writeResult.value = WriteExample[type].data
+}
+
+/** 点击重置的时候清空写作的结果**/
+const reset = () => {
+  writeResult.value = ''
+}
+</script>

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

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

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

+ 3 - 3
src/views/bpm/oa/leave/index.vue

@@ -36,9 +36,9 @@
           value-format="YYYY-MM-DD HH:mm:ss"
         />
       </el-form-item>
-      <el-form-item label="审批结果" prop="result">
+      <el-form-item label="审批结果" prop="status">
         <el-select
-          v-model="queryParams.result"
+          v-model="queryParams.status"
           class="!w-240px"
           clearable
           placeholder="请选择审批结果"
@@ -81,7 +81,7 @@
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
       <el-table-column align="center" label="申请编号" prop="id" />
-      <el-table-column align="center" label="状态" prop="result">
+      <el-table-column align="center" label="状态" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS" :value="scope.row.status" />
         </template>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است