Procházet zdrojové kódy

Merge remote-tracking branch 'yudao/dev' into dev

hhhero před 1 rokem
rodič
revize
1afdbe651e
100 změnil soubory, kde provedl 2954 přidání a 655 odebrání
  1. 5 0
      .env
  2. 10 11
      README.md
  3. 2 2
      src/api/ai/chat/conversation/index.ts
  4. 59 49
      src/api/ai/image/index.ts
  5. 41 0
      src/api/ai/music/index.ts
  6. 1 0
      src/api/infra/jobLog/index.ts
  7. 35 0
      src/api/mall/promotion/kefu/conversation/index.ts
  8. 36 0
      src/api/mall/promotion/kefu/message/index.ts
  9. 1 1
      src/api/mall/promotion/seckill/seckillConfig.ts
  10. binární
      src/assets/ai/dall2.jpg
  11. binární
      src/assets/ai/dall3.jpg
  12. binární
      src/assets/ai/qingxi.jpg
  13. binární
      src/assets/ai/ziran.jpg
  14. 4 5
      src/components/ContentWrap/src/ContentWrap.vue
  15. 1 1
      src/components/Icon/src/Icon.vue
  16. 1 1
      src/components/bpmnProcessDesigner/package/penal/flow-condition/FlowCondition.vue
  17. 1 0
      src/config/axios/service.ts
  18. 6 6
      src/layout/components/AppView.vue
  19. 20 0
      src/router/modules/remaining.ts
  20. 8 0
      src/utils/constants.ts
  21. 12 2
      src/utils/dict.ts
  22. 20 1
      src/utils/download.ts
  23. 1 1
      src/utils/index.ts
  24. 3 3
      src/views/Login/components/LoginForm.vue
  25. 0 0
      src/views/ai/chat/index/ChatEmpty.vue
  26. 52 58
      src/views/ai/chat/index/Conversation.vue
  27. 0 0
      src/views/ai/chat/index/Message.vue
  28. 0 0
      src/views/ai/chat/index/MessageLoading.vue
  29. 0 0
      src/views/ai/chat/index/MessageNewChat.vue
  30. 0 0
      src/views/ai/chat/index/components/ChatConversationUpdateForm.vue
  31. 0 0
      src/views/ai/chat/index/components/Header.vue
  32. 1 1
      src/views/ai/chat/index/index.vue
  33. 0 0
      src/views/ai/chat/index/role/RoleCategoryList.vue
  34. 0 0
      src/views/ai/chat/index/role/RoleList.vue
  35. 1 1
      src/views/ai/chat/index/role/index.vue
  36. 1 1
      src/views/ai/chat/manager/index.vue
  37. 0 140
      src/views/ai/image/ImageTask.vue
  38. 0 133
      src/views/ai/image/ImageTaskCard.vue
  39. 0 103
      src/views/ai/image/index.vue
  40. 22 34
      src/views/ai/image/index/ImageDetailDrawer.vue
  41. 233 0
      src/views/ai/image/index/ImageTask.vue
  42. 164 0
      src/views/ai/image/index/ImageTaskCard.vue
  43. 50 51
      src/views/ai/image/index/dall3/index.vue
  44. 142 0
      src/views/ai/image/index/index.vue
  45. 73 37
      src/views/ai/image/index/midjourney/index.vue
  46. 437 0
      src/views/ai/image/index/stable-diffusion/index.vue
  47. 251 0
      src/views/ai/image/manager/index.vue
  48. 21 0
      src/views/ai/music/components/index.vue
  49. 9 0
      src/views/ai/music/components/list/audioBar/index.vue
  50. 94 0
      src/views/ai/music/components/list/index.vue
  51. 29 0
      src/views/ai/music/components/list/songCard/index.vue
  52. 33 0
      src/views/ai/music/components/list/songInfo/index.vue
  53. 55 0
      src/views/ai/music/components/mode/desc.vue
  54. 44 0
      src/views/ai/music/components/mode/index.vue
  55. 83 0
      src/views/ai/music/components/mode/lyric.vue
  56. 25 0
      src/views/ai/music/components/title/index.vue
  57. 286 0
      src/views/ai/music/manager/index.vue
  58. 42 0
      src/views/ai/utils/constants.ts
  59. 13 0
      src/views/ai/utils/utils.ts
  60. 3 3
      src/views/bpm/oa/leave/index.vue
  61. 1 1
      src/views/bpm/processInstance/detail/index.vue
  62. 1 1
      src/views/crm/contract/detail/index.vue
  63. 1 5
      src/views/infra/job/index.vue
  64. 1 1
      src/views/infra/job/logger/JobLogDetail.vue
  65. 1 1
      src/views/infra/webSocket/index.vue
  66. 1 1
      src/views/mall/home/components/TradeTrendCard.vue
  67. 1 0
      src/views/mall/product/spu/form/ProductPropertyAddForm.vue
  68. 286 0
      src/views/mall/promotion/kefu/components/KeFuChatBox.vue
  69. 230 0
      src/views/mall/promotion/kefu/components/KeFuConversationBox.vue
  70. binární
      src/views/mall/promotion/kefu/components/images/a.png
  71. binární
      src/views/mall/promotion/kefu/components/images/aini.png
  72. binární
      src/views/mall/promotion/kefu/components/images/aixin.png
  73. binární
      src/views/mall/promotion/kefu/components/images/baiyan.png
  74. binární
      src/views/mall/promotion/kefu/components/images/bizui.png
  75. binární
      src/views/mall/promotion/kefu/components/images/buhaoyisi.png
  76. binární
      src/views/mall/promotion/kefu/components/images/bukesiyi.png
  77. binární
      src/views/mall/promotion/kefu/components/images/dajing.png
  78. binární
      src/views/mall/promotion/kefu/components/images/danao.png
  79. binární
      src/views/mall/promotion/kefu/components/images/daxiao.png
  80. binární
      src/views/mall/promotion/kefu/components/images/dianzan.png
  81. binární
      src/views/mall/promotion/kefu/components/images/emo.png
  82. binární
      src/views/mall/promotion/kefu/components/images/esi.png
  83. binární
      src/views/mall/promotion/kefu/components/images/fadai.png
  84. binární
      src/views/mall/promotion/kefu/components/images/fankun.png
  85. binární
      src/views/mall/promotion/kefu/components/images/feiwen.png
  86. binární
      src/views/mall/promotion/kefu/components/images/fennu.png
  87. binární
      src/views/mall/promotion/kefu/components/images/ganga.png
  88. binární
      src/views/mall/promotion/kefu/components/images/ganmao.png
  89. binární
      src/views/mall/promotion/kefu/components/images/hanyan.png
  90. binární
      src/views/mall/promotion/kefu/components/images/haochi.png
  91. binární
      src/views/mall/promotion/kefu/components/images/hongxin.png
  92. binární
      src/views/mall/promotion/kefu/components/images/huaixiao.png
  93. binární
      src/views/mall/promotion/kefu/components/images/jingkong.png
  94. binární
      src/views/mall/promotion/kefu/components/images/jingshu.png
  95. binární
      src/views/mall/promotion/kefu/components/images/jingya.png
  96. binární
      src/views/mall/promotion/kefu/components/images/kaixin.png
  97. binární
      src/views/mall/promotion/kefu/components/images/keai.png
  98. binární
      src/views/mall/promotion/kefu/components/images/keshui.png
  99. binární
      src/views/mall/promotion/kefu/components/images/kun.png
  100. binární
      src/views/mall/promotion/kefu/components/images/lengku.png

+ 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

+ 10 - 11
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 文件高亮            |
 
 ## 🔥 后端架构
 

+ 2 - 2
src/api/ai/chat/conversation/index.ts

@@ -43,8 +43,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` })
   },
 
   // 获得【我的】聊天对话列表

+ 59 - 49
src/api/ai/image/index.ts

@@ -1,51 +1,29 @@
 import request from '@/config/axios'
 
-// AI API 密钥 VO
-// TODO @fan:要不前端不弄太多 VO,就用这个 ImageDetailVO?!
-export interface ImageDetailVO {
+// AI 绘图 VO
+export interface ImageVO {
   id: number // 编号
+  platform: string // 平台
+  model: string // 模型
   prompt: string // 提示词
+  width: number // 图片宽度
+  height: number // 图片高度
   status: number // 状态
-  errorMessage: string // 错误信息
-  type: string // 模型下分不同的类型(清晰、真实...)
-  taskId: number // dr 任务id
+  publicStatus: boolean // 公开状态
   picUrl: string // 任务地址
-  originalPicUrl: string // 绘制图片地址
-  platform: string // 平台
-  model: string // 模型
-  style: string // 图像生成的风格
-  size: string // 图片尺寸
+  errorMessage: string // 错误信息
+  options: object // 配置 Map<string, string>
+  taskId: number // 任务编号
   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)
+  finishTime: string // 完成时间
 }
 
-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 // 图片高度
-}
-
 export interface ImageDrawReqVO {
   platform: string // 平台
   prompt: string // 提示词
@@ -65,34 +43,66 @@ export interface ImageMidjourneyImagineReqVO {
   version: string // 版本
 }
 
-// TODO 芋艿:review 下整体注释、方法名
+export interface ImageMjActionVO {
+  id: number // 图片编号
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+}
+
+export interface ImageMjButtonsVO {
+  customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
+  emoji: string // 图标 emoji
+  label: string // Make Variations 文本
+  style: number // 样式: 2(Primary)、3(Green)
+}
+
 // AI API 密钥 API
 export const ImageApi = {
-  // 获取 image 列表
-  getImageList: async (params: ImagePageReqVO) => {
+  // 获取【我的】绘图分页
+  getImagePageMy: async (params: ImagePageReqVO) => {
     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: ImageMjActionVO) => {
+    return await request.post({ url: `/ai/image/midjourney/action`, data })
   },
+
+  // ================ 绘图管理 ================
+
+  // 查询绘画分页
+  getImagePage: async (params: any) => {
+    return await request.get({ url: `/ai/image/page`, params })
+  },
+
+  // 更新绘画发布状态
+  updateImage: async (data: any) => {
+    return await request.put({ url: '/ai/image/update-public-status', data })
+  },
+
+  // 删除绘画
+  deleteImage: async (id: number) => {
+    return await request.delete({ url: `/ai/image/delete?id=` + id })
+  }
 }

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

@@ -0,0 +1,41 @@
+import request from '@/config/axios'
+
+// AI 音乐 VO
+export interface MusicVO {
+  id: number // 编号
+  userId: number // 用户编号
+  title: string // 音乐名称
+  lyric: string // 歌词
+  imageUrl: string // 图片地址
+  audioUrl: string // 音频地址
+  videoUrl: string // 视频地址
+  status: number // 音乐状态
+  gptDescriptionPrompt: string // 描述词
+  prompt: string // 提示词
+  platform: string // 模型平台
+  model: string // 模型
+  generateMode: number // 生成模式
+  tags: string // 音乐风格标签
+  duration: number // 音乐时长
+  publicStatus: boolean // 是否发布
+  taskId: string // 任务id
+  errorMessage: string // 错误信息
+}
+
+// AI 音乐 API
+export const MusicApi = {
+  // 查询音乐分页
+  getMusicPage: async (params: any) => {
+    return await request.get({ url: `/ai/music/page`, params })
+  },
+
+  // 更新音乐
+  updateMusic: async (data: any) => {
+    return await request.put({ url: '/ai/music/update', data })
+  },
+
+  // 删除音乐
+  deleteMusic: async (id: number) => {
+    return await request.delete({ url: `/ai/music/delete?id=` + id })
+  }
+}

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

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

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

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

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

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

+ 1 - 1
src/api/mall/promotion/seckill/seckillConfig.ts

@@ -19,7 +19,7 @@ export const SeckillConfigApi = {
 
   // 查询秒杀时段列表
   getSimpleSeckillConfigList: async () => {
-    return await request.get({ url: `/promotion/seckill-config/simple-list` })
+    return await request.get({ url: `/promotion/seckill-config/list` })
   },
 
   // 查询秒杀时段详情

binární
src/assets/ai/dall2.jpg


binární
src/assets/ai/dall3.jpg


binární
src/assets/ai/qingxi.jpg


binární
src/assets/ai/ziran.jpg


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

@@ -10,12 +10,13 @@ const prefixCls = getPrefixCls('content-wrap')
 
 defineProps({
   title: propTypes.string.def(''),
-  message: propTypes.string.def('')
+  message: propTypes.string.def(''),
+  bodyStyle: propTypes.object.def({ padding: '20px' })
 })
 </script>
 
 <template>
-  <ElCard :class="[prefixCls, 'mb-15px']" shadow="never">
+  <ElCard :body-style="bodyStyle" :class="[prefixCls, 'mb-15px']" shadow="never">
     <template v-if="title" #header>
       <div class="flex items-center">
         <span class="text-16px font-700">{{ title }}</span>
@@ -30,8 +31,6 @@ defineProps({
         </div>
       </div>
     </template>
-    <div>
-      <slot></slot>
-    </div>
+    <slot></slot>
   </ElCard>
 </template>

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

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

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

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

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

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

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

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

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

+ 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: '微信条码支付'

+ 12 - 2
src/utils/dict.ts

@@ -24,6 +24,10 @@ export interface NumberDictDataType extends DictDataType {
   value: number
 }
 
+export interface StringDictDataType extends DictDataType {
+  value: string
+}
+
 export const getDictOptions = (dictType: string) => {
   return dictStore.getDictByType(dictType) || []
 }
@@ -44,8 +48,11 @@ export const getIntDictOptions = (dictType: string): NumberDictDataType[] => {
 }
 
 export const getStrDictOptions = (dictType: string) => {
-  const dictOption: DictDataType[] = []
+  // 获得通用的 DictDataType 列表
   const dictOptions: DictDataType[] = getDictOptions(dictType)
+  // 转换成 string 类型的 StringDictDataType 类型
+  // why 需要特殊转换:避免 IDEA 在 v-for="dict in getStrDictOptions(...)" 时,el-option 的 key 会告警
+  const dictOption: StringDictDataType[] = []
   dictOptions.forEach((dict: DictDataType) => {
     dictOption.push({
       ...dict,
@@ -212,5 +219,8 @@ export enum DICT_TYPE {
   ERP_STOCK_RECORD_BIZ_TYPE = 'erp_stock_record_biz_type', // 库存明细的业务类型
 
   // ========== AI - 人工智能模块  ==========
-  AI_PLATFORM = 'ai_platform' // AI 平台
+  AI_PLATFORM = 'ai_platform', // AI 平台
+  AI_IMAGE_STATUS = 'ai_image_status', // AI 图片状态
+  AI_MUSIC_STATUS = 'ai_music_status', // AI 音乐状态
+  AI_GENERATE_MODE = 'ai_generate_mode' // AI 生成模式
 }

+ 20 - 1
src/utils/download.ts

@@ -29,10 +29,29 @@ const download = {
   html: (data: Blob, fileName: string) => {
     download0(data, fileName, 'text/html')
   },
-  // 下载 MarkdownView 方法
+  // 下载 Markdown 方法
   markdown: (data: Blob, fileName: string) => {
     download0(data, fileName, 'text/markdown')
   }
 }
 
 export default download
+
+/** 图片下载(通过浏览器图片下载)  */
+export 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()
+  }
+}

+ 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 // 默认记录我。如果不需要,可手动修改
   }

+ 0 - 0
src/views/ai/chat/ChatEmpty.vue → src/views/ai/chat/index/ChatEmpty.vue


+ 52 - 58
src/views/ai/chat/Conversation.vue → src/views/ai/chat/index/Conversation.vue

@@ -1,11 +1,10 @@
 <!--  AI 对话  -->
 <template>
-  <el-aside width="260px" class="conversation-container" style="height: 100%;">
-
+  <el-aside width="260px" class="conversation-container" style="height: 100%">
     <!-- 左顶部:对话 -->
-    <div style="height: 100%;">
+    <div style="height: 100%">
       <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
-        <Icon icon="ep:plus" class="mr-5px"/>
+        <Icon icon="ep:plus" class="mr-5px" />
         新建对话
       </el-button>
 
@@ -18,17 +17,19 @@
         @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" />
 
         <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,25 +41,27 @@
             @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="handlerTop(conversation)">
                   <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
                   <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
                 </el-button>
                 <el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
-                  <el-icon title="编辑" >
-                    <Icon icon="ep:edit"/>
+                  <el-icon 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>
@@ -66,20 +69,19 @@
           </div>
         </div>
         <!--  底部站位  -->
-        <div style="height: 160px; width: 100%;"></div>
+        <div style="height: 160px; width: 100%"></div>
       </div>
-
     </div>
 
     <!-- 左底部:工具栏 -->
     <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
     <div class="tool-box">
       <div @click="handleRoleRepository">
-        <Icon icon="ep:user"/>
+        <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>
@@ -88,17 +90,16 @@
 
     <!-- 角色仓库抽屉 -->
     <el-drawer v-model="drawer" title="角色仓库" size="754px">
-      <Role/>
+      <Role />
     </el-drawer>
-
   </el-aside>
 </template>
 
 <script setup lang="ts">
-import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
-import {ref} from "vue";
-import Role from "@/views/ai/chat/role/index.vue";
-import {Bottom, Top} from "@element-plus/icons-vue";
+import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
+import { ref } from 'vue'
+import Role from './role/index.vue'
+import { Bottom, Top } from '@element-plus/icons-vue'
 import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
 
 const message = useMessage() // 消息弹窗
@@ -107,8 +108,8 @@ 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 conversationList = ref([] as ChatConversationVO[]) // 对话列表
+const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
 const drawer = ref<boolean>(false) // 角色仓库抽屉 TODO @fan:roleDrawer 会不会好点哈
 const loading = ref<boolean>(false) // 加载中
 const loadingTime = ref<any>() // 加载中定时器
@@ -138,7 +139,7 @@ const searchConversation = async (e) => {
     conversationMap.value = await conversationTimeGroup(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)
@@ -150,7 +151,7 @@ const searchConversation = async (e) => {
  */
 const handleConversationClick = async (id: string) => {
   // 过滤出选中的对话
-  const filterConversation = conversationList.value.filter(item => {
+  const filterConversation = conversationList.value.filter((item) => {
     return item.id === id
   })
   // 回调 onConversationClick
@@ -211,20 +212,20 @@ const getChatConversationList = async () => {
 const conversationTimeGroup = async (list: ChatConversationVO[]) => {
   // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
   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;
+  const oneDay = 24 * 60 * 60 * 1000
+  const threeDays = 3 * oneDay
+  const sevenDays = 7 * oneDay
+  const thirtyDays = 30 * oneDay
   for (const conversation: ChatConversationVO of list) {
     // 置顶
     if (conversation.pinned) {
@@ -232,7 +233,7 @@ const conversationTimeGroup = async (list: ChatConversationVO[]) => {
       continue
     }
     // 计算时间差(单位:毫秒)
-    const diff = now - conversation.updateTime;
+    const diff = now - conversation.updateTime
     // 根据时间间隔判断
     if (diff < oneDay) {
       groupMap['今天'].push(conversation)
@@ -271,7 +272,7 @@ const createConversation = async () => {
  */
 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 +286,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) {
@@ -310,8 +311,7 @@ const deleteChatConversation = async (conversation: ChatConversationVO) => {
     await getChatConversationList()
     // 回调
     emits('onConversationDelete', conversation)
-  } catch {
-  }
+  } catch {}
 }
 
 /**
@@ -343,16 +343,13 @@ const handleRoleRepository = async () => {
  */
 const handleClearConversation = async () => {
   // TODO @fan:可以使用 await message.confirm( 简化,然后使用 await 改成同步的逻辑,会更简洁
-  ElMessageBox.confirm(
-    '确认后对话会全部清空,置顶的对话除外。',
-    '确认提示',
-    {
-      confirmButtonText: '确认',
-      cancelButtonText: '取消',
-      type: 'warning',
-    })
+  ElMessageBox.confirm('确认后对话会全部清空,置顶的对话除外。', '确认提示', {
+    confirmButtonText: '确认',
+    cancelButtonText: '取消',
+    type: 'warning'
+  })
     .then(async () => {
-      await ChatConversationApi.deleteMyAllExceptPinned()
+      await ChatConversationApi.deleteChatConversationMyByUnpinned()
       ElMessage({
         message: '操作成功!',
         type: 'success'
@@ -364,8 +361,7 @@ const handleClearConversation = async () => {
       // 回调 方法
       emits('onConversationClear')
     })
-    .catch(() => {
-    })
+    .catch(() => {})
 }
 
 // ============ 组件 onMounted
@@ -377,7 +373,7 @@ watch(activeId, async (newValue, oldValue) => {
 })
 
 // 定义 public 方法
-defineExpose({createConversation})
+defineExpose({ createConversation })
 
 onMounted(async () => {
   // 获取 对话列表
@@ -394,11 +390,9 @@ onMounted(async () => {
     }
   }
 })
-
 </script>
 
 <style scoped lang="scss">
-
 .conversation-container {
   position: relative;
   display: flex;

+ 0 - 0
src/views/ai/chat/Message.vue → src/views/ai/chat/index/Message.vue


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


+ 0 - 0
src/views/ai/chat/MessageNewChat.vue → src/views/ai/chat/index/MessageNewChat.vue


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


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


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

@@ -111,7 +111,7 @@ 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 ChatConversationUpdateForm from './components/ChatConversationUpdateForm.vue'
 import { Download, Top } from '@element-plus/icons-vue'
 
 const route = useRoute() // 路由

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


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


+ 1 - 1
src/views/ai/chat/role/index.vue → src/views/ai/chat/index/role/index.vue

@@ -68,7 +68,7 @@
 
 <script setup lang="ts">
 import { ref } from 'vue'
-import Header from '@/views/ai/chat/components/Header.vue'
+import Header from './../components/Header.vue'
 import RoleList from './RoleList.vue'
 import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
 import RoleCategoryList from './RoleCategoryList.vue'

+ 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 - 140
src/views/ai/image/ImageTask.vue

@@ -1,140 +0,0 @@
-<template>
-  <el-card class="dr-task" body-class="task-card" shadow="never">
-    <template #header>绘画任务</template>
-    <ImageTaskCard
-      v-for="image in imageList"
-      :key="image"
-      :image-detail="image"
-      @on-btn-click="handlerImageBtnClick"
-      @on-mj-btn-click="handlerImageMjBtnClick"/>
-  </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'
-
-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 详情
-
-/**  抽屉 - close  */
-const handlerDrawerClose = async () => {
-  isShowImageDetail.value = false
-}
-
-/**  任务 - detail  */
-const handlerDrawerOpen = async () => {
-  isShowImageDetail.value = true
-}
-
-/**
- * 获取 - image 列表
- */
-const getImageList = async () => {
-  const { list } = await ImageApi.getImageList({pageNo: 1, pageSize: 20})
-  imageList.value = list
-}
-
-/**  图片 - btn click  */
-const handlerImageBtnClick = async (type, imageDetail: ImageDetailVO) => {
-  // 获取 image detail id
-  showImageDetailId.value = 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()
-  }
-}
-
-/** 暴露组件方法 */
-defineExpose({getImageList})
-
-/** 组件挂在的时候 */
-onMounted(async () => {
-  // 获取 image 列表
-  await getImageList()
-  // 自动刷新 image 列表
-  imageListInterval.value = setInterval(async () => {
-    await getImageList()
-  }, 1000 * 20)
-})
-
-/** 组件取消挂在的时候 */
-onUnmounted(async () => {
-  if (imageListInterval.value) {
-    clearInterval(imageListInterval.value)
-  }
-})
-</script>
-
-<style lang="scss">
-.task-card {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: wrap;
-  align-content: flex-start;
-  height: 100%;
-  overflow: auto;
-
-  >div {
-    margin-right: 20px;
-    margin-bottom: 20px;
-  }
-}
-</style>
-
-<style scoped lang="scss">
-.dr-task {
-  width: 100%;
-  height: 100%;
-}
-</style>

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

@@ -1,133 +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} from "element-plus";
-
-const cardImageRef = ref<any>() // 卡片 image ref
-const cardImageLoadingInstance = ref<any>() // 卡片 image ref
-
-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) => {
-  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 - 103
src/views/ai/image/index.vue

@@ -1,103 +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绘画'" />
-      </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 ImageTask from './ImageTask.vue'
-
-// ref
-const imageTaskRef = ref<any>() // image task ref
-
-// 定义属性
-const selectModel = ref('DALL3绘画')
-const modelOptions = ['DALL3绘画', 'MJ绘画']
-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>

+ 22 - 34
src/views/ai/image/ImageDetailDrawer.vue → src/views/ai/image/index/ImageDetailDrawer.vue

@@ -2,16 +2,16 @@
   <el-drawer
     v-model="showDrawer"
     title="图片详细"
-    @close="handlerDrawerClose"
+    @close="handleDrawerClose"
     custom-class="drawer-class"
   >
     <!-- 图片 -->
     <div class="item">
-<!--      <div class="header">-->
-<!--        <div>图片</div>-->
-<!--        <div>-->
-<!--        </div>-->
-<!--      </div>-->
+      <!--      <div class="header">-->
+      <!--        <div>图片</div>-->
+      <!--        <div>-->
+      <!--        </div>-->
+      <!--      </div>-->
       <div class="body">
         <!-- TODO @fan: 要不,这里只展示图片???不用 ImageTaskCard -->
         <ImageTaskCard :image-detail="imageDetail" />
@@ -21,57 +21,49 @@
     <div class="item">
       <div class="tip">时间</div>
       <div class="body">
-        <div>提交时间:{{imageDetail.createTime}}</div>
-        <!-- TODO @fan:要不加个完成时间的字段 finishTime?updateTime 不算特别合理哈 -->
-        <div>生成时间:{{imageDetail.updateTime}}</div>
+        <div>提交时间:{{ imageDetail.createTime }}</div>
+        <div>生成时间:{{ imageDetail.finishTime }}</div>
       </div>
     </div>
     <!--  模型  -->
     <div class="item">
       <div class="tip">模型</div>
       <div class="body">
-        {{imageDetail.model}}({{imageDetail.height}}x{{imageDetail.width}})
+        {{ imageDetail.model }}({{ imageDetail.height }}x{{ imageDetail.width }})
       </div>
     </div>
     <!--  提示词  -->
     <div class="item">
       <div class="tip">提示词</div>
       <div class="body">
-        {{imageDetail.prompt}}
+        {{ imageDetail.prompt }}
       </div>
     </div>
     <!--  地址  -->
     <div class="item">
       <div class="tip">图片地址</div>
       <div class="body">
-        {{imageDetail.picUrl}}
-      </div>
-    </div>
-    <!--  生成地址 TODO @fan:这个字段我删除了,要不干掉? -->
-    <div class="item">
-      <div class="tip">生成地址</div>
-      <div class="body">
-        {{imageDetail.originalPicUrl}}
+        {{ imageDetail.picUrl }}
       </div>
     </div>
     <!-- 风格 -->
-    <div class="item">
+    <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}}
+        {{ imageDetail?.options?.style }}
       </div>
     </div>
   </el-drawer>
 </template>
 
 <script setup lang="ts">
-import {ImageApi, ImageDetailVO} from '@/api/ai/image';
-import ImageTaskCard from './ImageTaskCard.vue';
+import { ImageApi, ImageVO } from '@/api/ai/image'
+import ImageTaskCard from './ImageTaskCard.vue'
 
 const showDrawer = ref<boolean>(false) // 是否显示
-const imageDetail = ref<ImageDetailVO>({} as ImageDetailVO) // 图片详细信息
+const imageDetail = ref<ImageVO>({} as ImageVO) // 图片详细信息
 
 const props = defineProps({
   show: {
@@ -86,18 +78,18 @@ const props = defineProps({
 })
 
 /**  抽屉 - close  */
-const handlerDrawerClose = async () => {
-  emits('handlerDrawerClose')
+const handleDrawerClose = async () => {
+  emits('handleDrawerClose')
 }
 
 /**  获取 - 图片 detail  */
 const getImageDetail = async (id) => {
   // 获取图片详细
-  imageDetail.value = await ImageApi.getImageDetail(id)
+  imageDetail.value = await ImageApi.getImageMy(id)
 }
 
 /**  任务 - detail  */
-const handlerTaskDetail = async () => {
+const handleTaskDetail = async () => {
   showDrawer.value = true
 }
 
@@ -114,14 +106,11 @@ watch(id, async (newVal, oldVal) => {
   }
 })
 //
-const emits = defineEmits(['handlerDrawerClose'])
+const emits = defineEmits(['handleDrawerClose'])
 //
-onMounted(async () => {
-
-})
+onMounted(async () => {})
 </script>
 <style scoped lang="scss">
-
 .item {
   margin-bottom: 20px;
   width: 100%;
@@ -143,7 +132,6 @@ onMounted(async () => {
     margin-top: 10px;
     color: #616161;
 
-
     .taskImage {
       border-radius: 10px;
     }

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

@@ -0,0 +1,233 @@
+<template>
+  <el-card class="dr-task" body-class="task-card" shadow="never">
+    <template #header>绘画任务</template>
+    <div class="task-image-list" ref="imageTaskRef">
+      <ImageTaskCard
+        v-for="image in imageList"
+        :key="image"
+        :image-detail="image"
+        @on-btn-click="handleImageBtnClick"
+        @on-mj-btn-click="handleImageMjBtnClick"
+      />
+    </div>
+    <div class="task-image-pagination">
+      <el-pagination
+        background
+        layout="prev, pager, next"
+        :default-page-size="pageSize"
+        :total="pageTotal"
+        @change="handlePageChange"
+      />
+    </div>
+  </el-card>
+  <!-- 图片 detail 抽屉 -->
+  <ImageDetailDrawer
+    :show="isShowImageDetail"
+    :id="showImageDetailId"
+    @handle-drawer-close="handleDrawerClose"
+  />
+</template>
+<script setup lang="ts">
+import { ImageApi, ImageVO, ImageMjActionVO, ImageMjButtonsVO } from '@/api/ai/image'
+import ImageDetailDrawer from './ImageDetailDrawer.vue'
+import ImageTaskCard from './ImageTaskCard.vue'
+import { ElLoading, LoadingOptionsResolved } from 'element-plus'
+import { AiImageStatusEnum } from '@/views/ai/utils/constants'
+import { downloadImage } from '@/utils/download'
+
+const message = useMessage() // 消息弹窗
+
+const imageList = ref<ImageVO[]>([]) // image 列表
+const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
+const imageListInterval = ref<any>() // image 列表定时器,刷新列表
+const isShowImageDetail = ref<boolean>(false) // 是否显示 task 详情
+const showImageDetailId = ref<number>(0) // 是否显示 task 详情
+const imageTaskRef = ref<any>() // ref
+const imageTaskLoadingInstance = ref<any>() // loading
+const imageTaskLoading = ref<boolean>(false) // loading
+const pageNo = ref<number>(1) // page no
+const pageSize = ref<number>(10) // page size
+const pageTotal = ref<number>(0) // page size
+
+/**  抽屉 - close  */
+const handleDrawerClose = async () => {
+  isShowImageDetail.value = false
+}
+
+/**  任务 - detail  */
+const handleDrawerOpen = async () => {
+  isShowImageDetail.value = true
+}
+
+/**
+ * 获取 - image 列表
+ */
+const getImageList = async (apply: boolean = false) => {
+  imageTaskLoading.value = true
+  try {
+    imageTaskLoadingInstance.value = ElLoading.service({
+      target: imageTaskRef.value,
+      text: '加载中...'
+    } as LoadingOptionsResolved)
+    const { list, total } = await ImageApi.getImagePageMy({
+      pageNo: pageNo.value,
+      pageSize: pageSize.value
+    })
+    if (apply) {
+      imageList.value = [...imageList.value, ...list]
+    } else {
+      imageList.value = list
+    }
+    pageTotal.value = total
+    // 需要 watch 的数据
+    const newWatImages = {}
+    imageList.value.forEach((item) => {
+      if (item.status === AiImageStatusEnum.IN_PROGRESS) {
+        newWatImages[item.id] = item
+      }
+    })
+    inProgressImageMap.value = newWatImages
+  } finally {
+    if (imageTaskLoadingInstance.value) {
+      imageTaskLoadingInstance.value.close()
+      imageTaskLoadingInstance.value = null
+    }
+  }
+}
+
+/** 轮询生成中的 image 列表 */
+const refreshWatchImages = async () => {
+  const imageIds = Object.keys(inProgressImageMap.value).map(Number)
+  if (imageIds.length == 0) {
+    return
+  }
+  const list = (await ImageApi.getImageListMyByIds(imageIds)) as ImageVO[]
+  const newWatchImages = {}
+  list.forEach((image) => {
+    if (image.status === 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
+}
+
+/**  图片 - btn click  */
+const handleImageBtnClick = async (type: string, imageDetail: ImageVO) => {
+  // 获取 image detail id
+  showImageDetailId.value = imageDetail.id
+  // 处理不用 btn
+  if (type === 'more') {
+    await handleDrawerOpen()
+  } else if (type === 'delete') {
+    await message.confirm(`是否删除照片?`)
+    await ImageApi.deleteImageMy(imageDetail.id)
+    await getImageList()
+    message.success('删除成功!')
+  } else if (type === 'download') {
+    await downloadImage(imageDetail.picUrl)
+  } else if (type === 'regeneration') {
+    // Midjourney 平台
+    console.log('regeneration', imageDetail.id)
+    await emits('onRegeneration', imageDetail)
+  }
+}
+
+/**  图片 - mj btn click  */
+const handleImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageVO) => {
+  // 1、构建 params 参数
+  const data = {
+    id: imageDetail.id,
+    customId: button.customId
+  } as ImageMjActionVO
+  // 2、发送 action
+  await ImageApi.midjourneyAction(data)
+  // 3、刷新列表
+  await getImageList()
+}
+
+// page change
+const handlePageChange = async (page) => {
+  pageNo.value = page
+  await getImageList(false)
+}
+
+/** 暴露组件方法 */
+defineExpose({ getImageList })
+
+// emits
+const emits = defineEmits(['onRegeneration'])
+
+/** 组件挂在的时候 */
+onMounted(async () => {
+  // 获取 image 列表
+  await getImageList()
+  // 自动刷新 image 列表
+  imageListInterval.value = setInterval(async () => {
+    await refreshWatchImages()
+  }, 1000 * 3)
+})
+
+/** 组件取消挂在的时候 */
+onUnmounted(async () => {
+  if (imageListInterval.value) {
+    clearInterval(imageListInterval.value)
+  }
+})
+</script>
+
+<style lang="scss">
+.task-card {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  position: relative;
+}
+
+.task-image-list {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  align-content: flex-start;
+  height: 100%;
+  overflow: auto;
+  padding: 20px;
+  padding-bottom: 140px;
+  box-sizing: border-box; /* 确保内边距不会增加高度 */
+
+  > div {
+    margin-right: 20px;
+    margin-bottom: 20px;
+  }
+  > div:last-of-type {
+    //margin-bottom: 100px;
+  }
+}
+
+.task-image-pagination {
+  position: absolute;
+  bottom: 60px;
+  height: 50px;
+  line-height: 90px;
+  width: 100%;
+  z-index: 999;
+  background-color: #ffffff;
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+}
+</style>
+
+<style scoped lang="scss">
+.dr-task {
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 164 - 0
src/views/ai/image/index/ImageTaskCard.vue

@@ -0,0 +1,164 @@
+<template>
+  <el-card body-class="" class="image-card">
+    <div class="image-operation">
+      <div>
+        <el-button
+          type="primary"
+          text
+          bg
+          v-if="imageDetail?.status === AiImageStatusEnum.IN_PROGRESS"
+        >
+          生成中
+        </el-button>
+        <el-button text bg v-else-if="imageDetail?.status === AiImageStatusEnum.SUCCESS">
+          已完成
+        </el-button>
+        <el-button type="danger" text bg v-else-if="imageDetail?.status === AiImageStatusEnum.FAIL">
+          异常
+        </el-button>
+      </div>
+      <div>
+        <el-button
+          class="btn"
+          text
+          :icon="Download"
+          @click="handleBtnClick('download', imageDetail)"
+        />
+        <el-button
+          class="btn"
+          text
+          :icon="RefreshRight"
+          @click="handleBtnClick('regeneration', imageDetail)"
+        />
+        <el-button
+          class="btn"
+          text
+          :icon="Delete"
+          @click="handleBtnClick('delete', imageDetail)"
+        />
+        <el-button class="btn" text :icon="More" @click="handleBtnClick('more', imageDetail)" />
+      </div>
+    </div>
+    <div class="image-wrapper" ref="cardImageRef">
+      <!-- TODO @fan:要不加个点击,大图预览? -->
+      <img class="image" :src="imageDetail?.picUrl" />
+      <div v-if="imageDetail?.status === AiImageStatusEnum.FAIL">
+        {{ 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="handleMjBtnClick(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, ImageMjButtonsVO } from '@/api/ai/image'
+import { PropType } from 'vue'
+import {ElLoading, LoadingOptionsResolved} from 'element-plus'
+import { AiImageStatusEnum } from '@/views/ai/utils/constants'
+
+const cardImageRef = ref<any>() // 卡片 image ref
+const cardImageLoadingInstance = ref<any>() // 卡片 image ref
+const message = useMessage()
+const props = defineProps({
+  imageDetail: {
+    type: Object as PropType<ImageVO>,
+    require: true
+  }
+})
+
+/**  按钮 - 点击事件  */
+const handleBtnClick = async (type, imageDetail: ImageVO) => {
+  emits('onBtnClick', type, imageDetail)
+}
+
+const handleLoading = async (status: number) => {
+  // TODO @芋艿:这个搞成 Loading 组件,然后通过数据驱动,这样搞可以哇?
+  if (status === AiImageStatusEnum.IN_PROGRESS) {
+    cardImageLoadingInstance.value = ElLoading.service({
+      target: cardImageRef.value,
+      text: '生成中...'
+    } as LoadingOptionsResolved)
+  } else {
+    if (cardImageLoadingInstance.value) {
+      cardImageLoadingInstance.value.close()
+      cardImageLoadingInstance.value = null
+    }
+  }
+}
+
+/**  mj 按钮 click  */
+const handleMjBtnClick = 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 handleLoading(newVal.status as string)
+})
+
+// emits
+const emits = defineEmits(['onBtnClick', 'onMjBtnClick'])
+
+//
+onMounted(async () => {
+  await handleLoading(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>

+ 50 - 51
src/views/ai/image/dall3/index.vue → src/views/ai/image/index/dall3/index.vue

@@ -25,7 +25,7 @@
                  :type="(selectHotWord === hotWord ? 'primary' : 'default')"
                  v-for="hotWord in hotWords"
                  :key="hotWord"
-                 @click="handlerHotWordClick(hotWord)"
+                 @click="handleHotWordClick(hotWord)"
       >
         {{ hotWord }}
       </el-button>
@@ -37,7 +37,7 @@
     </div>
     <el-space wrap class="model-list">
       <div
-        :class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
+        :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
         v-for="model in models"
         :key="model.key"
 
@@ -45,7 +45,7 @@
         <el-image
           :src="model.image"
           fit="contain"
-          @click="handlerModelClick(model)"
+          @click="handleModelClick(model)"
         />
         <div class="model-font">{{model.name}}</div>
       </div>
@@ -57,14 +57,14 @@
     </div>
     <el-space wrap class="image-style-list">
       <div
-        :class="selectImageStyle === imageStyle ? 'image-style-item selectImageStyle' : 'image-style-item'"
+        :class="selectImageStyle === imageStyle.key ? '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)"
+          @click="handleStyleClick(imageStyle)"
         />
         <div class="style-font">{{imageStyle.name}}</div>
       </div>
@@ -78,8 +78,8 @@
       <div class="size-item"
            v-for="imageSize in imageSizeList"
            :key="imageSize.key"
-           @click="handlerSizeClick(imageSize)">
-        <div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
+           @click="handleSizeClick(imageSize)">
+        <div :class="selectImageSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'">
           <div :style="imageSize.style"></div>
         </div>
         <div class="size-font">{{ imageSize.name }}</div>
@@ -91,13 +91,13 @@
                size="large"
                round
                :loading="drawIn"
-               @click="handlerGenerateImage">
+               @click="handleGenerateImage">
       {{drawIn ? '生成中' : '生成内容'}}
     </el-button>
   </div>
 </template>
 <script setup lang="ts">
-import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
+import {ImageApi, ImageDrawReqVO, ImageVO} from '@/api/ai/image';
 
 // image 模型
 interface ImageModelVO {
@@ -120,40 +120,38 @@ const prompt = ref<string>('')  // 提示词
 const drawIn = ref<boolean>(false)  // 生成中
 const selectHotWord = ref<string>('') // 选中的热词
 const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
-const selectModel = ref<any>({}) // 模型
-// TODO @fan:image 改成项目里自己的哈
-// TODO @fan:这个 image,要不看看网上有没合适的图片,作为占位符,啊哈哈
+const selectModel = ref<string>('dall-e-3') // 模型
+// message
+const message = useMessage()
 const models = ref<ImageModelVO[]>([
   {
     key: 'dall-e-3',
     name: 'DALL·E 3',
-    image: 'https://h5.cxyhub.com/images/model_2.png',
+    image: `/src/assets/ai/dall2.jpg`,
   },
   {
     key: 'dall-e-2',
     name: 'DALL·E 2',
-    image: 'https://h5.cxyhub.com/images/model_1.png',
+    image: `/src/assets/ai/dall3.jpg`,
   },
 ])  // 模型
-selectModel.value = models.value[0]
 
-const selectImageStyle = ref<any>({}) // style 样式
-// TODO @fan:image 改成项目里自己的哈
+const selectImageStyle = ref<string>('vivid') // style 样式
+
 const imageStyleList = ref<ImageModelVO[]>([
   {
     key: 'vivid',
     name: '清晰',
-    image: 'https://h5.cxyhub.com/images/model_1.png',
+    image: `/src/assets/ai/qingxi.jpg`,
   },
   {
     key: 'natural',
     name: '自然',
-    image: 'https://h5.cxyhub.com/images/model_2.png',
+    image: `/src/assets/ai/ziran.jpg`,
   },
 ])  // style
-selectImageStyle.value = imageStyleList.value[0]
 
-const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
+const selectImageSize = ref<string>('1024x1024') // 选中 size
 const imageSizeList = ref<ImageSizeVO[]>([
   {
     key: '1024x1024',
@@ -177,17 +175,14 @@ const imageSizeList = ref<ImageSizeVO[]>([
     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) => {
+const handleHotWordClick = async (hotWord: string) => {
   // 取消选中
   if (selectHotWord.value == hotWord) {
     selectHotWord.value = ''
@@ -200,62 +195,66 @@ const handlerHotWordClick = async (hotWord: string) => {
 }
 
 /**  模型 - click  */
-const handlerModelClick = async (model: ImageModelVO) => {
-  if (selectModel.value === model) {
-    selectModel.value = {} as ImageModelVO
-    return
-  }
-  selectModel.value = model
+const handleModelClick = async (model: ImageModelVO) => {
+  selectModel.value = model.key
 }
 
 /**  样式 - click  */
-const handlerStyleClick = async (imageStyle: ImageModelVO) => {
-  if (selectImageStyle.value === imageStyle) {
-    selectImageStyle.value = {} as ImageModelVO
-    return
-  }
-  selectImageStyle.value = imageStyle
+const handleStyleClick = async (imageStyle: ImageModelVO) => {
+  selectImageStyle.value = imageStyle.key
 }
 
 /**  size - click  */
-const handlerSizeClick = async (imageSize: ImageSizeVO) => {
-  if (selectImageSize.value === imageSize) {
-    selectImageSize.value = {} as ImageSizeVO
-    return
-  }
-  selectImageSize.value = imageSize
+const handleSizeClick = async (imageSize: ImageSizeVO) => {
+  selectImageSize.value = imageSize.key
 }
 
 /**  图片生产  */
-const handlerGenerateImage = async () => {
+const handleGenerateImage = async () => {
+  // 二次确认
+  await message.confirm(`确认生成内容?`)
   try {
     // 加载中
     drawIn.value = true
     // 回调
-    emits('onDrawStart', selectModel.value.key)
+    emits('onDrawStart', selectModel.value)
+    const imageSize = imageSizeList.value.find(item => item.key === selectImageSize.value) as ImageSizeVO
     const form = {
       platform: 'OpenAI',
       prompt: prompt.value, // 提示词
-      model: selectModel.value.key, // 模型
-      width: selectImageSize.value.width, // size 不能为空
-      height: selectImageSize.value.height, // size 不能为空
+      model: selectModel.value, // 模型
+      width: imageSize.width, // size 不能为空
+      height: imageSize.height, // size 不能为空
       options: {
-        style: selectImageStyle.value.key, // 图像生成的风格
+        style: selectImageStyle.value, // 图像生成的风格
       }
     } as ImageDrawReqVO
     // 发送请求
     await ImageApi.drawImage(form)
   } finally {
     // 回调
-    emits('onDrawComplete', selectModel.value.key)
+    emits('onDrawComplete', selectModel.value)
     // 加载结束
     drawIn.value = false
   }
 }
 
+/** 填充值 */
+const settingValues = async (imageDetail: ImageVO) => {
+  prompt.value = imageDetail.prompt
+  selectModel.value = imageDetail.model
+  //
+  selectImageStyle.value = imageDetail.options?.style
+  //
+  const imageSize = imageSizeList.value.find(item => item.key === `${imageDetail.width}x${imageDetail.height}`) as ImageSizeVO
+  await handleSizeClick(imageSize)
+}
+
+/** 暴露组件方法 */
+defineExpose({ settingValues })
+
 </script>
 <style scoped lang="scss">
-
 // 提示词
 .prompt {
 }

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

@@ -0,0 +1,142 @@
+<!-- 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"
+        />
+      </div>
+    </div>
+    <div class="main">
+      <ImageTask ref="imageTaskRef" @on-regeneration="handleRegeneration" />
+    </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'
+import { AiPlatformEnum } from '@/views/ai/utils/constants'
+import {ImageVO} from "@/api/ai/image";
+
+
+const imageTaskRef = ref<any>() // image task ref
+const dall3Ref = ref<any>() // openai ref
+const midjourneyRef = ref<any>() // midjourney ref
+const stableDiffusionRef = ref<any>() // stable diffusion ref
+
+// 定义属性
+const selectPlatform = ref('StableDiffusion')
+const platformOptions = [
+  {
+    label: 'DALL3 绘画',
+    value: AiPlatformEnum.OPENAI
+  },
+  {
+    label: 'MJ 绘画',
+    value: AiPlatformEnum.MIDJOURNEY
+  },
+  {
+    label: 'Stable Diffusion',
+    value: AiPlatformEnum.STABLE_DIFFUSION
+  }
+]
+
+/**  绘画 - start  */
+const handleDrawStart = async (type) => {
+}
+
+/**  绘画 - complete  */
+const handleDrawComplete = async (type) => {
+  await imageTaskRef.value.getImageList()
+}
+
+/**  绘画 - 重新生成  */
+const handleRegeneration = async (imageDetail: ImageVO) => {
+  // 切换平台
+  selectPlatform.value = imageDetail.platform
+  console.log('切换平台', imageDetail.platform)
+  // 根据不同平台填充 imageDetail
+  if (imageDetail.platform === AiPlatformEnum.MIDJOURNEY) {
+    await nextTick(async () => {
+      midjourneyRef.value.settingValues(imageDetail)
+    })
+  } else if (imageDetail.platform === AiPlatformEnum.OPENAI) {
+    await nextTick(async () => {
+      dall3Ref.value.settingValues(imageDetail)
+    })
+  } else if (imageDetail.platform === AiPlatformEnum.STABLE_DIFFUSION) {
+    await nextTick(async () => {
+      stableDiffusionRef.value.settingValues(imageDetail)
+    })
+  }
+
+}
+</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>

+ 73 - 37
src/views/ai/image/midjourney/index.vue → src/views/ai/image/index/midjourney/index.vue

@@ -24,7 +24,7 @@
                  :type="(selectHotWord === hotWord ? 'primary' : 'default')"
                  v-for="hotWord in hotWords"
                  :key="hotWord"
-                 @click="handlerHotWordClick(hotWord)"
+                 @click="handleHotWordClick(hotWord)"
       >
         {{ hotWord }}
       </el-button>
@@ -38,8 +38,8 @@
       <div class="size-item"
            v-for="imageSize in imageSizeList"
            :key="imageSize.key"
-           @click="handlerSizeClick(imageSize)">
-        <div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
+           @click="handleSizeClick(imageSize)">
+        <div :class="selectImageSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'">
           <div :style="imageSize.style"></div>
         </div>
         <div class="size-font">{{ imageSize.key }}</div>
@@ -57,7 +57,7 @@
         clearable
         placeholder="请选择版本"
         style="width: 350px"
-        @change="handlerChangeVersion"
+        @change="handleChangeVersion"
       >
         <el-option
           v-for="item in versionList"
@@ -74,7 +74,7 @@
     </div>
     <el-space wrap class="model-list">
       <div
-        :class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
+        :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
         v-for="model in models"
         :key="model.key"
 
@@ -82,22 +82,31 @@
         <el-image
           :src="model.image"
           fit="contain"
-          @click="handlerModelClick(model)"
+          @click="handleModelClick(model)"
         />
         <div class="model-font">{{model.name}}</div>
       </div>
     </el-space>
   </div>
+  <div class="model">
+    <div>
+      <el-text tag="b">参考图</el-text>
+    </div>
+    <el-space wrap class="model-list">
+      <UploadImg v-model="referImage" height="80px" width="80px" />
+    </el-space>
+  </div>
   <div class="btns">
     <!--    <el-button size="large" round>重置内容</el-button>-->
-    <el-button type="primary" size="large" round @click="handlerGenerateImage">生成内容</el-button>
+    <el-button type="primary" size="large" round @click="handleGenerateImage">生成内容</el-button>
   </div>
 </template>
 <script setup lang="ts">
 
 // image 模型
-import {ImageApi, ImageMidjourneyImagineReqVO} from "@/api/ai/image";
-
+import {ImageApi, ImageMidjourneyImagineReqVO, ImageVO} from "@/api/ai/image";
+// message
+const message = useMessage()
 // 定义 emits
 const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
 
@@ -117,9 +126,10 @@ interface ImageSizeVO {
 
 // 定义属性
 const prompt = ref<string>('')  // 提示词
+const referImage = ref<any>()  // 参考图
 const selectHotWord = ref<string>('') // 选中的热词
 const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
-const selectModel = ref<any>() // 选中的热词
+const selectModel = ref<string>('midjourney') // 选中的热词
 const models = ref<ImageModelVO[]>([
   {
     key: 'midjourney',
@@ -132,9 +142,8 @@ const models = ref<ImageModelVO[]>([
     image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png',
   },
 ])  // 模型
-selectModel.value = models.value[0] // 默认选中
 
-const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // 选中 size
+const selectImageSize = ref<string>('1:1') // 选中 size
 const imageSizeList = ref<ImageSizeVO[]>([
   {
     key: '1:1',
@@ -167,11 +176,9 @@ const imageSizeList = ref<ImageSizeVO[]>([
     style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
   },
 ]) // size
-selectImageSize.value = imageSizeList.value[0]
-
 
 // version
-const versionList = ref<any>([
+const midjourneyVersionList = ref<any>([
   {
     value: '6.0',
     label: 'v6.0',
@@ -192,14 +199,19 @@ const versionList = ref<any>([
     value: '4.0',
     label: 'v4.0',
   },
-]) // version 列表
+])
+const nijiVersionList = ref<any>([
+  {
+    value: '5',
+    label: 'v5',
+  },
+])
 const selectVersion = ref<any>('6.0') // 选中的 version
-
-// 定义 Props
-const props = defineProps({})
+let versionList = ref<any>([]) // version 列表
+versionList.value = midjourneyVersionList.value // 默认选择 midjourney
 
 /**  热词 - click  */
-const handlerHotWordClick = async (hotWord: string) => {
+const handleHotWordClick = async (hotWord: string) => {
   // 取消
   if (selectHotWord.value == hotWord) {
     selectHotWord.value = ''
@@ -212,45 +224,69 @@ const handlerHotWordClick = async (hotWord: string) => {
 }
 
 /**  size - click  */
-const handlerSizeClick = async (imageSize: ImageSizeVO) => {
-  if (selectImageSize.value === imageSize) {
-    selectImageSize.value = {} as ImageSizeVO
-    return
-  }
-  selectImageSize.value = imageSize
+const handleSizeClick = async (imageSize: ImageSizeVO) => {
+  selectImageSize.value = imageSize.key
 }
 
 /**  模型 - click  */
-const handlerModelClick = async (model: ImageModelVO) => {
-  selectModel.value = model
+const handleModelClick = async (model: ImageModelVO) => {
+  selectModel.value = model.key
+  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) => {
+const handleChangeVersion = async (version) => {
   console.log('version', version)
 }
 
 /** 图片生产  */
-const handlerGenerateImage = async () => {
-  // todo @范 图片生产逻辑
+const handleGenerateImage = async () => {
+  // 二次确认
+  await message.confirm(`确认生成内容?`)
+  // todo @芋艿 图片生产逻辑
   try {
     // 回调
-    emits('onDrawStart', selectModel.value.key)
+    emits('onDrawStart', selectModel.value)
     // 发送请求
+    const imageSize = imageSizeList.value.find(item => selectImageSize.value === item.key) as ImageSizeVO
     const req = {
       prompt: prompt.value,
-      model: selectModel.value.key,
-      width: selectImageSize.value.width,
-      height: selectImageSize.value.height,
+      model: selectModel.value,
+      width: imageSize.width,
+      height: imageSize.height,
       version: selectVersion.value,
-      base64Array: [],
+      referImageUrl: referImage.value,
     } as ImageMidjourneyImagineReqVO
     await ImageApi.midjourneyImagine(req)
   } finally {
     // 回调
-    emits('onDrawComplete', selectModel.value.key)
+    emits('onDrawComplete', selectModel.value)
   }
 }
+
+/** 填充值 */
+const settingValues = async (imageDetail: ImageVO) => {
+  // 提示词
+  prompt.value = imageDetail.prompt
+  // image size
+  const imageSize = imageSizeList.value.find(item => item.key === `${imageDetail.width}:${imageDetail.height}`) as ImageSizeVO
+  selectImageSize.value = imageSize.key
+  // 选中模型
+  const model = models.value.find(item => item.key === imageDetail.options?.model) as ImageModelVO
+  await handleModelClick(model)
+  // 版本
+  selectVersion.value = versionList.value.find(item => item.value === imageDetail.options?.version).value
+  // image
+  referImage.value = imageDetail.options.referImageUrl
+}
+
+/** 暴露组件方法 */
+defineExpose({ settingValues })
 </script>
 <style scoped lang="scss">
 

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

@@ -0,0 +1,437 @@
+<!-- dall3 -->
+<template>
+  <div class="prompt">
+    <el-text tag="b">画面描述</el-text>
+    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
+    <!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
+    <el-input
+      v-model="prompt"
+      maxlength="1024"
+      rows="5"
+      style="width: 100%; margin-top: 15px"
+      input-style="border-radius: 7px;"
+      placeholder="例如:童话里的小屋应该是什么样子?"
+      show-word-limit
+      type="textarea"
+    />
+  </div>
+  <div class="hot-words">
+    <div>
+      <el-text tag="b">随机热词</el-text>
+    </div>
+    <el-space wrap class="word-list">
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in hotWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
+      >
+        {{ hotWord }}
+      </el-button>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">采样方法</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="selectSampler" placeholder="Select" size="large" style="width: 350px">
+        <el-option v-for="item in sampler" :key="item.key" :label="item.name" :value="item.key" />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">CLIP</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select
+        v-model="selectClipGuidancePreset"
+        placeholder="Select"
+        size="large"
+        style="width: 350px"
+      >
+        <el-option
+          v-for="item in clipGuidancePresets"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">风格</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-select v-model="selectStylePreset" placeholder="Select" size="large" style="width: 350px">
+        <el-option
+          v-for="item in stylePresets"
+          :key="item.key"
+          :label="item.name"
+          :value="item.key"
+        />
+      </el-select>
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">图片尺寸</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input v-model="imageWidth" style="width: 170px" placeholder="图片宽度" />
+      <el-input v-model="imageHeight" style="width: 170px" placeholder="图片高度" />
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">迭代步数</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input
+        v-model="steps"
+        type="number"
+        size="large"
+        style="width: 350px"
+        placeholder="Please input"
+      />
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">引导系数</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input
+        v-model="scale"
+        type="number"
+        size="large"
+        style="width: 350px"
+        placeholder="Please input"
+      />
+    </el-space>
+  </div>
+  <div class="group-item">
+    <div>
+      <el-text tag="b">随机因子</el-text>
+    </div>
+    <el-space wrap class="group-item-body">
+      <el-input
+        v-model="seed"
+        type="number"
+        size="large"
+        style="width: 350px"
+        placeholder="Please input"
+      />
+    </el-space>
+  </div>
+  <div class="btns">
+    <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
+  </div>
+</template>
+<script setup lang="ts">
+import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
+import { hasChinese } from '@/views/ai/utils/utils'
+
+// image 模型
+interface ImageModelVO {
+  key: string
+  name: string
+}
+
+// 定义属性
+const prompt = ref<string>('') // 提示词
+const drawIn = ref<boolean>(false) // 生成中
+const selectHotWord = ref<string>('') // 选中的热词
+const imageWidth = ref<number>(512) // 图片宽度
+const imageHeight = ref<number>(512) // 图片高度
+
+const hotWords = ref<string[]>([
+  '中国旗袍',
+  '古装美女',
+  '卡通头像',
+  '机甲战士',
+  '童话小屋',
+  '中国长城'
+]) // 热词
+// message
+const message = useMessage()
+
+// 采样方法
+const selectSampler = ref<string>('DDIM') // 模型
+// DDIM DDPM K_DPMPP_2M K_DPMPP_2S_ANCESTRAL K_DPM_2 K_DPM_2_ANCESTRAL K_EULER K_EULER_ANCESTRAL K_HEUN K_LMS
+const sampler = ref<ImageModelVO[]>([
+  {
+    key: 'DDIM',
+    name: 'DDIM'
+  },
+  {
+    key: 'DDPM',
+    name: 'DDPM'
+  },
+  {
+    key: 'K_DPMPP_2M',
+    name: 'K_DPMPP_2M'
+  },
+  {
+    key: 'K_DPMPP_2S_ANCESTRAL',
+    name: 'K_DPMPP_2S_ANCESTRAL'
+  },
+  {
+    key: 'K_DPM_2',
+    name: 'K_DPM_2'
+  },
+  {
+    key: 'K_DPM_2_ANCESTRAL',
+    name: 'K_DPM_2_ANCESTRAL'
+  },
+  {
+    key: 'K_EULER',
+    name: 'K_EULER'
+  },
+  {
+    key: 'K_EULER_ANCESTRAL',
+    name: 'K_EULER_ANCESTRAL'
+  },
+  {
+    key: 'K_HEUN',
+    name: 'K_HEUN'
+  },
+  {
+    key: 'K_LMS',
+    name: 'K_LMS'
+  }
+])
+
+// 风格
+// 3d-model analog-film anime cinematic comic-book digital-art enhance fantasy-art isometric
+// line-art low-poly modeling-compound neon-punk origami photographic pixel-art tile-texture
+const selectStylePreset = ref<string>('3d-model') // 模型
+const stylePresets = ref<ImageModelVO[]>([
+  {
+    key: '3d-model',
+    name: '3d-model'
+  },
+  {
+    key: 'analog-film',
+    name: 'analog-film'
+  },
+  {
+    key: 'anime',
+    name: 'anime'
+  },
+  {
+    key: 'cinematic',
+    name: 'cinematic'
+  },
+  {
+    key: 'comic-book',
+    name: 'comic-book'
+  },
+  {
+    key: 'digital-art',
+    name: 'digital-art'
+  },
+  {
+    key: 'enhance',
+    name: 'enhance'
+  },
+  {
+    key: 'fantasy-art',
+    name: 'fantasy-art'
+  },
+  {
+    key: 'isometric',
+    name: 'isometric'
+  },
+  {
+    key: 'line-art',
+    name: 'line-art'
+  },
+  {
+    key: 'low-poly',
+    name: 'low-poly'
+  },
+  {
+    key: 'modeling-compound',
+    name: 'modeling-compound'
+  },
+  // neon-punk origami photographic pixel-art tile-texture
+  {
+    key: 'neon-punk',
+    name: 'neon-punk'
+  },
+  {
+    key: 'origami',
+    name: 'origami'
+  },
+  {
+    key: 'photographic',
+    name: 'photographic'
+  },
+  {
+    key: 'pixel-art',
+    name: 'pixel-art'
+  },
+  {
+    key: 'tile-texture',
+    name: 'tile-texture'
+  }
+])
+
+// 文本提示相匹配的图像(clip_guidance_preset) 简称 CLIP
+// https://platform.stability.ai/docs/api-reference#tag/SDXL-and-SD1.6/operation/textToImage
+// FAST_BLUE FAST_GREEN NONE SIMPLE SLOW SLOWER SLOWEST
+const selectClipGuidancePreset = ref<string>('NONE') // 模型
+const clipGuidancePresets = ref<ImageModelVO[]>([
+  {
+    key: 'NONE',
+    name: 'NONE'
+  },
+  {
+    key: 'FAST_BLUE',
+    name: 'FAST_BLUE'
+  },
+  {
+    key: 'FAST_GREEN',
+    name: 'FAST_GREEN'
+  },
+  {
+    key: 'SIMPLE',
+    name: 'SIMPLE'
+  },
+  {
+    key: 'SLOW',
+    name: 'SLOW'
+  },
+  {
+    key: 'SLOWER',
+    name: 'SLOWER'
+  },
+  {
+    key: 'SLOWEST',
+    name: 'SLOWEST'
+  }
+])
+
+const steps = ref<number>(20) // 迭代步数
+const seed = ref<number>(42) // 控制生成图像的随机性
+const scale = ref<number>(7.5) // 引导系数
+
+// 定义 Props
+const props = defineProps({})
+// 定义 emits
+const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
+
+/** 热词 - click  */
+const handleHotWordClick = async (hotWord: string) => {
+  // 取消选中
+  if (selectHotWord.value == hotWord) {
+    selectHotWord.value = ''
+    return
+  }
+  // 选中
+  selectHotWord.value = hotWord
+  // 替换提示词
+  prompt.value = hotWord
+}
+
+/**  图片生产  */
+const handleGenerateImage = async () => {
+  // 二次确认
+  await message.confirm(`确认生成内容?`)
+  if (await hasChinese(prompt.value)) {
+    message.alert('暂不支持中文!')
+    return
+  }
+  try {
+    // 加载中
+    drawIn.value = true
+    // 回调
+    emits('onDrawStart', 'StableDiffusion')
+    // 发送请求
+    const form = {
+      platform: 'StableDiffusion',
+      model: 'stable-diffusion-v1-6',
+      prompt: prompt.value, // 提示词
+      width: imageWidth.value, // 图片宽度
+      height: imageHeight.value, // 图片高度
+      options: {
+        seed: seed.value, // 随机种子
+        steps: steps.value, // 图片生成步数
+        scale: scale.value, // 引导系数
+        sampler: selectSampler.value, // 采样算法
+        clipGuidancePreset: selectClipGuidancePreset.value, // 文本提示相匹配的图像 CLIP
+        stylePreset: selectStylePreset.value // 风格
+      }
+    } as ImageDrawReqVO
+    await ImageApi.drawImage(form)
+  } finally {
+    // 回调
+    emits('onDrawComplete', 'StableDiffusion')
+    // 加载结束
+    drawIn.value = false
+  }
+}
+
+/** 填充值 */
+const settingValues = async (imageDetail: ImageVO) => {
+  prompt.value = imageDetail.prompt
+  imageWidth.value = imageDetail.width
+  imageHeight.value = imageDetail.height
+  seed.value = imageDetail.options?.seed
+  steps.value = imageDetail.options?.steps
+  scale.value = imageDetail.options?.scale
+  selectSampler.value = imageDetail.options?.sampler
+  selectClipGuidancePreset.value = imageDetail.options?.clipGuidancePreset
+  selectStylePreset.value = imageDetail.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>

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

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

@@ -0,0 +1,21 @@
+<template>
+  <div class="flex h-1/1">
+    <!-- 模式 -->
+    <Mode class="flex-none" @generate-music="generateMusic"/>
+    <!-- 音频列表 -->
+    <List ref="listRef" class="flex-auto"/>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Mode from './mode/index.vue'
+import List from './list/index.vue'
+
+defineOptions({ name: 'Index' })
+
+const listRef = ref<{generateMusic: (...args) => void} | null>(null)
+
+function generateMusic (args: {formData: Recordable}) {
+ unref(listRef)?.generateMusic(args.formData)
+}
+</script>

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,55 @@
+<template>
+  <div>
+    <Title title="音乐/歌词说明" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲">
+      <el-input
+        v-model="formData.desc"
+        :autosize="{ minRows: 6, maxRows: 6}"
+        resize="none"
+        type="textarea"
+        maxlength="1200"
+        show-word-limit
+        placeholder="一首关于糟糕分手的欢快歌曲"
+      />
+    </Title>
+
+    <Title title="纯音乐" desc="创建一首没有歌词的歌曲">
+      <template #extra>
+        <el-switch v-model="formData.pure" size="small"/>
+      </template>
+    </Title>
+
+    <Title title="版本" desc="描述您想要的音乐风格和主题,使用流派和氛围而不是特定的艺术家和歌曲">
+      <el-select v-model="formData.version" placeholder="请选择">
+        <el-option
+          v-for="item in [{
+            value: '3',
+            label: 'V3'
+          }, {
+            value: '2',
+            label: 'V2'
+          }]"
+          :key="item.value"
+          :label="item.label"
+          :value="item.value"
+        />
+      </el-select>
+    </Title>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import Title from '../title/index.vue'
+
+defineOptions({ name: 'Desc' })
+
+const formData = reactive({
+  desc: '',
+  pure: false,
+  version: '3'
+})
+
+defineExpose({
+  formData
+})
+
+</script>

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

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

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

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

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

@@ -0,0 +1,25 @@
+<template>
+  <div class="mb-12px">
+    <div class="flex text-[var(--el-text-color-primary)] justify-between items-center">
+      <span>{{title}}</span>
+      <slot name="extra"></slot>
+    </div>
+    <div class="text-[var(--el-text-color-secondary)] text-12px my-8px">
+      {{desc}}
+    </div>
+    <slot></slot>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'Index' })
+
+defineProps({
+  title: {
+    type: String
+  },
+  desc: {
+    type: String
+  }
+})
+</script>

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

@@ -0,0 +1,286 @@
+<template>
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      class="-mb-15px"
+      :model="queryParams"
+      ref="queryFormRef"
+      :inline="true"
+      label-width="68px"
+    >
+      <el-form-item label="用户编号" prop="userId">
+        <el-input
+          v-model="queryParams.userId"
+          placeholder="请输入用户编号"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="音乐名称" prop="title">
+        <el-input
+          v-model="queryParams.title"
+          placeholder="请输入音乐名称"
+          clearable
+          @keyup.enter="handleQuery"
+          class="!w-240px"
+        />
+      </el-form-item>
+      <el-form-item label="音乐状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          placeholder="请选择音乐状态"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_MUSIC_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="生成模式" prop="generateMode">
+        <el-select
+          v-model="queryParams.generateMode"
+          placeholder="请选择生成模式"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.AI_GENERATE_MODE)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          value-format="YYYY-MM-DD HH:mm:ss"
+          type="daterange"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-220px"
+        />
+      </el-form-item>
+      <el-form-item label="是否发布" prop="publicStatus">
+        <el-select
+          v-model="queryParams.publicStatus"
+          placeholder="请选择是否发布"
+          clearable
+          class="!w-240px"
+        >
+          <el-option
+            v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
+        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
+      <el-table-column label="编号" align="center" prop="id" width="180" fixed="left" />
+      <el-table-column label="音乐名称" align="center" prop="title" width="180px" fixed="left" />
+      <el-table-column label="用户" align="center" prop="userId" width="180">
+        <template #default="scope">
+          <span>{{ userList.find((item) => item.id === scope.row.userId)?.nickname }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="音乐状态" align="center" prop="status" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_MUSIC_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column label="模型" align="center" prop="model" width="180" />
+      <el-table-column label="内容" align="center" width="180">
+        <template #default="{ row }">
+          <el-link
+            v-if="row.audioUrl?.length > 0"
+            type="primary"
+            :href="row.audioUrl"
+            target="_blank"
+          >
+            音乐
+          </el-link>
+          <el-link
+            v-if="row.videoUrl?.length > 0"
+            type="primary"
+            :href="row.videoUrl"
+            target="_blank"
+            class="!pl-5px"
+          >
+            视频
+          </el-link>
+          <el-link
+            v-if="row.imageUrl?.length > 0"
+            type="primary"
+            :href="row.imageUrl"
+            target="_blank"
+            class="!pl-5px"
+          >
+            封面
+          </el-link>
+        </template>
+      </el-table-column>
+      <el-table-column label="时长(秒)" align="center" prop="duration" width="100" />
+      <el-table-column label="提示词" align="center" prop="prompt" width="180" />
+      <el-table-column label="歌词" align="center" prop="lyric" width="180" />
+      <el-table-column label="描述" align="center" prop="gptDescriptionPrompt" width="180" />
+      <el-table-column label="生成模式" align="center" prop="generateMode" width="100">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.AI_GENERATE_MODE" :value="scope.row.generateMode" />
+        </template>
+      </el-table-column>
+      <el-table-column label="风格标签" align="center" prop="tags" width="180">
+        <template #default="scope">
+          <el-tag v-for="tag in scope.row.tags" :key="tag" round class="ml-2px">
+            {{ tag }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="是否发布" align="center" prop="publicStatus">
+        <template #default="scope">
+          <el-switch
+            v-model="scope.row.publicStatus"
+            :active-value="true"
+            :inactive-value="false"
+            @change="handleUpdatePublicStatusChange(scope.row)"
+            :disabled="scope.row.status !== 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>

+ 42 - 0
src/views/ai/utils/constants.ts

@@ -0,0 +1,42 @@
+/**
+ * Created by 芋道源码
+ *
+ * AI 枚举类
+ *
+ * 问题:为什么不放在 src/utils/constants.ts 呢?
+ * 回答:主要 AI 是可选模块,考虑到独立、解耦,所以放在了 /views/ai/utils/constants.ts
+ */
+
+/**
+ * AI 平台的枚举
+ */
+export const AiPlatformEnum = {
+  TONG_YI: 'TongYi', // 阿里
+  YI_YAN: 'YiYan', // 百度
+  DEEP_SEEK: 'DeepSeek', // DeepSeek
+  ZHI_PU: 'ZhiPu', // 智谱 AI
+  XING_HUO: 'XingHuo', // 讯飞
+  OPENAI: 'OpenAI',
+  Ollama: 'Ollama',
+  STABLE_DIFFUSION: 'StableDiffusion', // Stability AI
+  MIDJOURNEY: 'Midjourney', // Midjourney
+  SUNO: 'Suno' // Suno AI
+}
+
+/**
+ * AI 图像生成状态的枚举
+ */
+export const AiImageStatusEnum = {
+  IN_PROGRESS: 10, // 进行中
+  SUCCESS: 20, // 已完成
+  FAIL: 30 // 已失败
+}
+
+/**
+ * AI 音乐生成状态的枚举
+ */
+export const AiMusicStatusEnum = {
+  IN_PROGRESS: 10, // 进行中
+  SUCCESS: 20, // 已完成
+  FAIL: 30 // 已失败
+}

+ 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 = async (str) => {
+  return /[\u4e00-\u9fa5]/.test(str)
+}

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

+ 1 - 1
src/views/bpm/processInstance/detail/index.vue

@@ -363,7 +363,7 @@ const loadRunningTask = (tasks) => {
     // 2.4 处理 approve 表单
     if (task.formId && task.formConf) {
       const approveForm = {}
-      setConfAndFields2(approveForm, task.formConf, task.formFields, task.formVariable)
+      setConfAndFields2(approveForm, task.formConf, task.formFields, task.formVariables)
       approveForms.value.push(approveForm)
     } else {
       approveForms.value.push({}) // 占位,避免为空

+ 1 - 1
src/views/crm/contract/detail/index.vue

@@ -36,7 +36,7 @@
           ref="permissionListRef"
           :biz-id="contract.id!"
           :biz-type="BizTypeEnum.CRM_CONTRACT"
-          :show-action="false"
+          :show-action="!permissionListRef?.isPool || false"
           @quit-team="close"
         />
       </el-tab-pane>

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

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

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

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

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

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

+ 1 - 1
src/views/mall/home/components/TradeTrendCard.vue

@@ -186,7 +186,7 @@ const getOrderCountTrendComparison = async (
     dates.push(item.value.date)
     if (series.length === 2) {
       series[0].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额
-      series[1].data.push(fenToYuan(item?.value?.orderPayCount || 0)) // 当前数量
+      series[1].data.push(item?.value?.orderPayCount || 0) // 当前数量
     } else {
       series[0].data.push(fenToYuan(item?.reference?.orderPayPrice || 0)) // 对照金额
       series[1].data.push(fenToYuan(item?.value?.orderPayPrice || 0)) // 当前金额

+ 1 - 0
src/views/mall/product/spu/form/ProductPropertyAddForm.vue

@@ -7,6 +7,7 @@
       :model="formData"
       :rules="formRules"
       label-width="80px"
+      @keydown.enter.prevent="submitForm"
     >
       <el-form-item label="属性名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入名称" />

+ 286 - 0
src/views/mall/promotion/kefu/components/KeFuChatBox.vue

@@ -0,0 +1,286 @@
+<template>
+  <el-container v-if="showChatBox" class="kefu">
+    <el-header>
+      <div class="kefu-title">{{ keFuConversation.userNickname }}</div>
+    </el-header>
+    <el-main class="kefu-content" style="overflow: visible">
+      <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)">
+        <div ref="innerRef" class="w-[100%] pb-3px">
+          <div v-for="(item, index) in messageList" :key="item.id" class="w-[100%]">
+            <div class="flex justify-center items-center mb-20px">
+              <!-- 日期 -->
+              <div
+                v-if="
+                  item.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(item, index)
+                "
+                class="date-message"
+              >
+                {{ formatDate(item.createTime) }}
+              </div>
+              <!-- 系统消息 -->
+              <div
+                v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
+                class="system-message"
+              >
+                {{ item.content }}
+              </div>
+            </div>
+            <div
+              :class="[
+                item.senderType === UserTypeEnum.MEMBER
+                  ? `ss-row-left`
+                  : item.senderType === UserTypeEnum.ADMIN
+                    ? `ss-row-right`
+                    : ''
+              ]"
+              class="flex mb-20px w-[100%]"
+            >
+              <el-avatar
+                v-if="item.senderType === UserTypeEnum.MEMBER"
+                :src="keFuConversation.userAvatar"
+                alt="avatar"
+              />
+              <div
+                :class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }"
+                class="p-10px"
+              >
+                <!-- 文本消息 -->
+                <TextMessageItem :message="item" />
+                <!-- 图片消息 -->
+                <ImageMessageItem :message="item" />
+              </div>
+              <el-avatar
+                v-if="item.senderType === UserTypeEnum.ADMIN"
+                :src="item.senderAvatar"
+                alt="avatar"
+              />
+            </div>
+          </div>
+        </div>
+      </el-scrollbar>
+    </el-main>
+    <el-footer height="230px">
+      <div class="h-[100%]">
+        <div class="chat-tools flex items-center">
+          <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
+          <PictureSelectUpload
+            class="ml-15px mt-3px cursor-pointer"
+            @send-picture="handleSendPicture"
+          />
+        </div>
+        <el-input v-model="message" :rows="6" style="border-style: none" type="textarea" />
+        <div class="h-45px flex justify-end">
+          <el-button class="mt-10px" type="primary" @click="handleSendMessage">发送</el-button>
+        </div>
+      </div>
+    </el-footer>
+  </el-container>
+  <el-empty v-else description="请选择左侧的一个会话后开始" />
+</template>
+
+<script lang="ts" setup>
+import { ElScrollbar as ElScrollbarType } from 'element-plus'
+import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import EmojiSelectPopover from './tools/EmojiSelectPopover.vue'
+import PictureSelectUpload from './tools/PictureSelectUpload.vue'
+import TextMessageItem from './message/TextMessageItem.vue'
+import ImageMessageItem from './message/ImageMessageItem.vue'
+import { Emoji } from './tools/emoji'
+import { KeFuMessageContentTypeEnum } from './tools/constants'
+import { isEmpty } from '@/utils/is'
+import { UserTypeEnum } from '@/utils/constants'
+import { formatDate } from '@/utils/formatTime'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+
+dayjs.extend(relativeTime)
+
+defineOptions({ name: 'KeFuMessageBox' })
+const messageTool = useMessage()
+const message = ref('') // 消息
+const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
+const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
+// 获得消息 TODO puhui999:  先不考虑下拉加载历史消息
+const getMessageList = async (conversation: KeFuConversationRespVO) => {
+  keFuConversation.value = conversation
+  const { list } = await KeFuMessageApi.getKeFuMessagePage({
+    pageNo: 1,
+    conversationId: conversation.id
+  })
+  messageList.value = list.reverse()
+  // TODO puhui999: 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
+  await scrollToBottom()
+}
+// 刷新消息列表
+const refreshMessageList = () => {
+  if (!keFuConversation.value) {
+    return
+  }
+  getMessageList(keFuConversation.value)
+}
+defineExpose({ getMessageList, refreshMessageList })
+// 是否显示聊天区域
+const showChatBox = computed(() => !isEmpty(keFuConversation.value))
+// 处理表情选择
+const handleEmojiSelect = (item: Emoji) => {
+  message.value += item.name
+}
+// 处理图片发送
+const handleSendPicture = async (picUrl: string) => {
+  // 组织发送消息
+  const msg = {
+    conversationId: keFuConversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.IMAGE,
+    content: picUrl
+  }
+  await sendMessage(msg)
+}
+// 发送消息
+const handleSendMessage = async () => {
+  // 1. 校验消息是否为空
+  if (isEmpty(unref(message.value))) {
+    messageTool.warning('请输入消息后再发送哦!')
+    return
+  }
+  // 2. 组织发送消息
+  const msg = {
+    conversationId: keFuConversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.TEXT,
+    content: message.value
+  }
+  await sendMessage(msg)
+}
+
+// 发送消息 【共用】
+const sendMessage = async (msg: any) => {
+  // 发送消息
+  await KeFuMessageApi.sendKeFuMessage(msg)
+  message.value = ''
+  // 加载消息列表
+  await getMessageList(keFuConversation.value)
+  // 滚动到最新消息处
+  await scrollToBottom()
+}
+
+const innerRef = ref<HTMLDivElement>()
+const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
+// 滚动到底部
+const scrollToBottom = async () => {
+  // 1. 滚动到最新消息
+  await nextTick()
+  scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
+  // 2. 消息已读
+  await KeFuMessageApi.updateKeFuMessageReadStatus(keFuConversation.value.id)
+}
+/**
+ * 是否显示时间
+ * @param {*} item - 数据
+ * @param {*} index - 索引
+ */
+const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
+  if (unref(messageList.value)[index + 1]) {
+    let dateString = dayjs(unref(messageList.value)[index + 1].createTime).fromNow()
+    return dateString !== dayjs(unref(item).createTime).fromNow()
+  }
+  return false
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  &-title {
+    border-bottom: #e4e0e0 solid 1px;
+    height: 60px;
+    line-height: 60px;
+  }
+
+  &-content {
+    .ss-row-left {
+      justify-content: flex-start;
+
+      .kefu-message {
+        margin-left: 20px;
+        position: relative;
+
+        &::before {
+          content: '';
+          width: 10px;
+          height: 10px;
+          left: -19px;
+          top: calc(50% - 10px);
+          position: absolute;
+          border-left: 5px solid transparent;
+          border-bottom: 5px solid transparent;
+          border-top: 5px solid transparent;
+          border-right: 5px solid #ffffff;
+        }
+      }
+    }
+
+    .ss-row-right {
+      justify-content: flex-end;
+
+      .kefu-message {
+        margin-right: 20px;
+        position: relative;
+
+        &::after {
+          content: '';
+          width: 10px;
+          height: 10px;
+          right: -19px;
+          top: calc(50% - 10px);
+          position: absolute;
+          border-left: 5px solid #ffffff;
+          border-bottom: 5px solid transparent;
+          border-top: 5px solid transparent;
+          border-right: 5px solid transparent;
+        }
+      }
+    }
+
+    // 消息气泡
+    .kefu-message {
+      color: #333;
+      border-radius: 5px;
+      box-shadow: 3px 5px 15px rgba(0, 0, 0, 0.2);
+      padding: 5px 10px;
+      width: auto;
+      max-width: 50%;
+      text-align: left;
+      display: inline-block !important;
+      position: relative;
+      word-break: break-all;
+      background-color: #ffffff;
+      transition: all 0.2s;
+
+      &:hover {
+        transform: scale(1.03);
+      }
+    }
+
+    .date-message,
+    .system-message {
+      width: fit-content;
+      border-radius: 12rpx;
+      padding: 8rpx 16rpx;
+      margin-bottom: 16rpx;
+      background-color: #e8e8e8;
+      color: #999;
+      font-size: 24rpx;
+    }
+  }
+
+  .chat-tools {
+    width: 100%;
+    border: #e4e0e0 solid 1px;
+    border-radius: 10px;
+    height: 44px;
+  }
+
+  ::v-deep(textarea) {
+    resize: none;
+  }
+}
+</style>

+ 230 - 0
src/views/mall/promotion/kefu/components/KeFuConversationBox.vue

@@ -0,0 +1,230 @@
+<template>
+  <div class="kefu">
+    <div
+      v-for="(item, index) in conversationList"
+      :key="item.id"
+      :class="{ active: index === activeConversationIndex, pinned: item.adminPinned }"
+      class="kefu-conversation flex items-center"
+      @click="openRightMessage(item, index)"
+      @contextmenu.prevent="rightClick($event as PointerEvent, item)"
+    >
+      <div class="flex justify-center items-center w-100%">
+        <div class="flex justify-center items-center" style="width: 50px; height: 50px">
+          <el-badge
+            :hidden="item.adminUnreadMessageCount === 0"
+            :max="99"
+            :value="item.adminUnreadMessageCount"
+          >
+            <el-avatar :src="item.userAvatar" alt="avatar" />
+          </el-badge>
+        </div>
+        <div class="ml-10px w-100%">
+          <div class="flex justify-between items-center w-100%">
+            <span>{{ item.userNickname }}</span>
+            <span class="color-[#989EA6]">
+              {{ formatDate(item.lastMessageTime) }}
+            </span>
+          </div>
+          <!-- 文本消息 -->
+          <template v-if="KeFuMessageContentTypeEnum.TEXT === item.lastMessageContentType">
+            <div
+              v-dompurify-html="replaceEmoji(item.lastMessageContent)"
+              class="last-message flex items-center color-[#989EA6]"
+            ></div>
+          </template>
+          <!-- 图片消息 -->
+          <template v-else>
+            <div class="last-message flex items-center color-[#989EA6]">
+              {{ getContentType(item.lastMessageContentType) }}
+            </div>
+          </template>
+        </div>
+      </div>
+    </div>
+    <!-- 通过右击获取到的坐标定位 -->
+    <ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
+      <li
+        v-show="!selectedConversation.adminPinned"
+        class="flex items-center"
+        @click.stop="updateConversationPinned(true)"
+      >
+        <Icon class="mr-5px" icon="ep:top" />
+        置顶会话
+      </li>
+      <li
+        v-show="selectedConversation.adminPinned"
+        class="flex items-center"
+        @click.stop="updateConversationPinned(false)"
+      >
+        <Icon class="mr-5px" icon="ep:bottom" />
+        取消置顶
+      </li>
+      <li class="flex items-center" @click.stop="deleteConversation">
+        <Icon class="mr-5px" color="red" icon="ep:delete" />
+        删除会话
+      </li>
+      <li class="flex items-center" @click.stop="closeRightMenu">
+        <Icon class="mr-5px" color="red" icon="ep:close" />
+        取消
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import { useEmoji } from './tools/emoji'
+import { formatDate, getNowDateTime } from '@/utils/formatTime'
+import { KeFuMessageContentTypeEnum } from './tools/constants'
+
+defineOptions({ name: 'KeFuConversationBox' })
+const message = useMessage()
+const { replaceEmoji } = useEmoji()
+const activeConversationIndex = ref(-1) // 选中的会话
+const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表
+const getConversationList = async () => {
+  conversationList.value = await KeFuConversationApi.getConversationList()
+  // 测试数据
+  for (let i = 0; i < 5; i++) {
+    conversationList.value.push({
+      id: 1,
+      userId: 283,
+      userAvatar:
+        'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKMezSxtOImrC9lbhwHiazYwck3xwrEcO7VJfG6WQo260whaeVNoByE5RreiaGsGfOMlIiaDhSaA991w/132',
+      userNickname: '辉辉鸭' + i,
+      lastMessageTime: getNowDateTime(),
+      lastMessageContent:
+        '[爱心][爱心]你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇',
+      lastMessageContentType: 1,
+      adminPinned: false,
+      userDeleted: false,
+      adminDeleted: false,
+      adminUnreadMessageCount: i
+    })
+  }
+}
+defineExpose({ getConversationList })
+const emits = defineEmits<{
+  (e: 'change', v: KeFuConversationRespVO): void
+}>()
+// 打开右侧消息
+const openRightMessage = (item: KeFuConversationRespVO, index: number) => {
+  activeConversationIndex.value = index
+  emits('change', item)
+}
+// 获得消息类型
+const getContentType = computed(() => (lastMessageContentType: number) => {
+  switch (lastMessageContentType) {
+    case KeFuMessageContentTypeEnum.SYSTEM:
+      return '[系统消息]'
+    case KeFuMessageContentTypeEnum.VIDEO:
+      return '[视频消息]'
+    case KeFuMessageContentTypeEnum.IMAGE:
+      return '[图片消息]'
+    case KeFuMessageContentTypeEnum.PRODUCT:
+      return '[商品消息]'
+    case KeFuMessageContentTypeEnum.ORDER:
+      return '[订单消息]'
+    case KeFuMessageContentTypeEnum.VOICE:
+      return '[语音消息]'
+    default:
+      return ''
+  }
+})
+
+//======================= 右键菜单 =======================
+const showRightMenu = ref(false) // 显示右键菜单
+const rightMenuStyle = ref<any>({}) // 右键菜单 Style
+const selectedConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 右键选中的会话对象
+// 右键菜单
+const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
+  selectedConversation.value = item
+  // 显示右键菜单
+  showRightMenu.value = true
+  rightMenuStyle.value = {
+    top: mouseEvent.clientY - 110 + 'px',
+    left: mouseEvent.clientX - 80 + 'px'
+  }
+}
+// 关闭菜单
+const closeRightMenu = () => {
+  showRightMenu.value = false
+}
+// 置顶会话
+const updateConversationPinned = async (adminPinned: boolean) => {
+  // 1. 会话置顶/取消置顶
+  await KeFuConversationApi.updateConversationPinned({
+    id: selectedConversation.value.id,
+    adminPinned
+  })
+  // TODO puhui999: 快速操作两次提示只会提示一次看看怎么优雅解决
+  message.success(adminPinned ? '置顶成功' : '取消置顶成功')
+  // 2. 关闭右键菜单,更新会话列表
+  closeRightMenu()
+  await getConversationList()
+}
+// 删除会话
+const deleteConversation = async () => {
+  // 1. 删除会话
+  await message.confirm('您确定要删除该会话吗?')
+  await KeFuConversationApi.deleteConversation(selectedConversation.value.id)
+  // 2. 关闭右键菜单,更新会话列表
+  closeRightMenu()
+  await getConversationList()
+}
+watch(showRightMenu, (val) => {
+  if (val) {
+    document.body.addEventListener('click', closeRightMenu)
+  } else {
+    document.body.removeEventListener('click', closeRightMenu)
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  &-conversation {
+    height: 60px;
+    padding: 10px;
+    background-color: #fff;
+    transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
+
+    .last-message {
+      width: 200px;
+      overflow: hidden; // 隐藏超出的文本
+      white-space: nowrap; // 禁止换行
+      text-overflow: ellipsis; // 添加省略号
+    }
+  }
+
+  .active {
+    border-left: 5px #3271ff solid;
+    background-color: #eff0f1;
+  }
+
+  .pinned {
+    background-color: #eff0f1;
+  }
+
+  .right-menu-ul {
+    position: absolute;
+    background-color: #fff;
+    padding: 10px;
+    margin: 0;
+    list-style-type: none; /* 移除默认的项目符号 */
+    border-radius: 12px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+    width: 130px;
+
+    li {
+      padding: 8px 16px;
+      cursor: pointer;
+      border-radius: 12px;
+      transition: background-color 0.3s; /* 平滑过渡 */
+      &:hover {
+        background-color: #e0e0e0; /* 悬停时的背景颜色 */
+      }
+    }
+  }
+}
+</style>

binární
src/views/mall/promotion/kefu/components/images/a.png


binární
src/views/mall/promotion/kefu/components/images/aini.png


binární
src/views/mall/promotion/kefu/components/images/aixin.png


binární
src/views/mall/promotion/kefu/components/images/baiyan.png


binární
src/views/mall/promotion/kefu/components/images/bizui.png


binární
src/views/mall/promotion/kefu/components/images/buhaoyisi.png


binární
src/views/mall/promotion/kefu/components/images/bukesiyi.png


binární
src/views/mall/promotion/kefu/components/images/dajing.png


binární
src/views/mall/promotion/kefu/components/images/danao.png


binární
src/views/mall/promotion/kefu/components/images/daxiao.png


binární
src/views/mall/promotion/kefu/components/images/dianzan.png


binární
src/views/mall/promotion/kefu/components/images/emo.png


binární
src/views/mall/promotion/kefu/components/images/esi.png


binární
src/views/mall/promotion/kefu/components/images/fadai.png


binární
src/views/mall/promotion/kefu/components/images/fankun.png


binární
src/views/mall/promotion/kefu/components/images/feiwen.png


binární
src/views/mall/promotion/kefu/components/images/fennu.png


binární
src/views/mall/promotion/kefu/components/images/ganga.png


binární
src/views/mall/promotion/kefu/components/images/ganmao.png


binární
src/views/mall/promotion/kefu/components/images/hanyan.png


binární
src/views/mall/promotion/kefu/components/images/haochi.png


binární
src/views/mall/promotion/kefu/components/images/hongxin.png


binární
src/views/mall/promotion/kefu/components/images/huaixiao.png


binární
src/views/mall/promotion/kefu/components/images/jingkong.png


binární
src/views/mall/promotion/kefu/components/images/jingshu.png


binární
src/views/mall/promotion/kefu/components/images/jingya.png


binární
src/views/mall/promotion/kefu/components/images/kaixin.png


binární
src/views/mall/promotion/kefu/components/images/keai.png


binární
src/views/mall/promotion/kefu/components/images/keshui.png


binární
src/views/mall/promotion/kefu/components/images/kun.png


binární
src/views/mall/promotion/kefu/components/images/lengku.png


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů