Browse Source

Merge remote-tracking branch 'yudao-ui-admin-vue3/dev' into dev

# Conflicts:
#	src/views/ai/utils/constants.ts
hhhero 1 year ago
parent
commit
a68ae29f3a
30 changed files with 1812 additions and 1236 deletions
  1. 9 14
      src/api/ai/image/index.ts
  2. 0 1
      src/assets/ai/clear.svg
  3. 18 19
      src/utils/download.ts
  4. 7 8
      src/views/ai/chat/index/components/message/MessageList.vue
  5. 5 7
      src/views/ai/chat/index/components/role/RoleList.vue
  6. 8 12
      src/views/ai/chat/index/components/role/RoleRepository.vue
  7. 15 16
      src/views/ai/chat/index/index.vue
  8. 0 140
      src/views/ai/image/index/ImageDetailDrawer.vue
  9. 52 54
      src/views/ai/image/index/components/ImageCard.vue
  10. 224 0
      src/views/ai/image/index/components/ImageDetail.vue
  11. 90 86
      src/views/ai/image/index/components/ImageList.vue
  12. 63 136
      src/views/ai/image/index/components/dall3/index.vue
  13. 91 172
      src/views/ai/image/index/components/midjourney/index.vue
  14. 272 0
      src/views/ai/image/index/components/stableDiffusion/index.vue
  15. 26 39
      src/views/ai/image/index/index.vue
  16. 0 437
      src/views/ai/image/index/stable-diffusion/index.vue
  17. 8 9
      src/views/ai/model/chatRole/ChatRoleForm.vue
  18. 305 0
      src/views/ai/utils/constants.ts
  19. 1 1
      src/views/ai/utils/utils.ts
  20. 0 1
      src/views/bpm/category/index.vue
  21. 24 12
      src/views/mall/product/spu/components/SkuList.vue
  22. 6 6
      src/views/mall/product/spu/form/ProductAttributes.vue
  23. 7 7
      src/views/mall/product/spu/form/SkuForm.vue
  24. 114 14
      src/views/mall/promotion/kefu/components/KeFuChatBox.vue
  25. 28 31
      src/views/mall/promotion/kefu/components/KeFuConversationBox.vue
  26. 182 0
      src/views/mall/promotion/kefu/components/message/OrderMessageItem.vue
  27. 195 0
      src/views/mall/promotion/kefu/components/message/ProductItem.vue
  28. 36 0
      src/views/mall/promotion/kefu/components/message/ProductMessageItem.vue
  29. 1 0
      src/views/mall/promotion/kefu/components/tools/constants.ts
  30. 25 14
      src/views/mall/promotion/kefu/index.vue

+ 9 - 14
src/api/ai/image/index.ts

@@ -12,16 +12,11 @@ export interface ImageVO {
   publicStatus: boolean // 公开状态
   picUrl: string // 任务地址
   errorMessage: string // 错误信息
-  options: object // 配置 Map<string, string>
+  options: any // 配置 Map<string, string>
   taskId: number // 任务编号
-  buttons: ImageMjButtonsVO[] // mj 操作按钮
-  createTime: string // 创建时间
-  finishTime: string // 完成时间
-}
-
-export interface ImagePageReqVO {
-  pageNo: number // 分页编号
-  pageSize: number // 分页大小
+  buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮
+  createTime: Date // 创建时间
+  finishTime: Date // 完成时间
 }
 
 export interface ImageDrawReqVO {
@@ -43,22 +38,22 @@ export interface ImageMidjourneyImagineReqVO {
   version: string // 版本
 }
 
-export interface ImageMjActionVO {
+export interface ImageMidjourneyActionVO {
   id: number // 图片编号
   customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
 }
 
-export interface ImageMjButtonsVO {
+export interface ImageMidjourneyButtonsVO {
   customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
   emoji: string // 图标 emoji
   label: string // Make Variations 文本
   style: number // 样式: 2(Primary)、3(Green)
 }
 
-// AI API 密钥 API
+// AI 图片 API
 export const ImageApi = {
   // 获取【我的】绘图分页
-  getImagePageMy: async (params: ImagePageReqVO) => {
+  getImagePageMy: async (params: PageParam) => {
     return await request.get({ url: `/ai/image/my-page`, params })
   },
   // 获取【我的】绘图记录
@@ -85,7 +80,7 @@ export const ImageApi = {
     return await request.post({ url: `/ai/image/midjourney/imagine`, data })
   },
   // 【Midjourney】Action 操作(二次生成图片)
-  midjourneyAction: async (data: ImageMjActionVO) => {
+  midjourneyAction: async (data: ImageMidjourneyActionVO) => {
     return await request.post({ url: `/ai/image/midjourney/action`, data })
   },
 

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

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

+ 18 - 19
src/utils/download.ts

@@ -32,26 +32,25 @@ const download = {
   // 下载 Markdown 方法
   markdown: (data: Blob, fileName: string) => {
     download0(data, fileName, 'text/markdown')
+  },
+  // 下载图片(允许跨域)
+  image: (url: string) => {
+    const image = new Image()
+    image.setAttribute('crossOrigin', 'anonymous')
+    image.src = url
+    image.onload = () => {
+      const canvas = document.createElement('canvas')
+      canvas.width = image.width
+      canvas.height = image.height
+      const ctx = canvas.getContext('2d') as CanvasDrawImage
+      ctx.drawImage(image, 0, 0, image.width, image.height)
+      const url = canvas.toDataURL('image/png')
+      const a = document.createElement('a')
+      a.href = url
+      a.download = 'image.png'
+      a.click()
+    }
   }
 }
 
 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()
-  }
-}

+ 7 - 8
src/views/ai/chat/index/components/message/MessageList.vue

@@ -1,5 +1,5 @@
 <template>
-  <div ref="messageContainer" class="h-100% overflow-y relative">
+  <div ref="messageContainer" class="h-100% overflow-y-auto relative">
     <div class="chat-list" v-for="(item, index) in list" :key="index">
       <!-- 靠左 message:system、assistant 类型 -->
       <div class="left-message message-item" v-if="item.type !== 'user'">
@@ -101,13 +101,12 @@ const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义
 
 /** 滚动到底部 */
 const scrollToBottom = async (isIgnore?: boolean) => {
-  // 注意要使用 nextTick 以免获取不到dom
-  await nextTick(() => {
-    if (isIgnore || !isScrolling.value) {
-      messageContainer.value.scrollTop =
-        messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
-    }
-  })
+  // 注意要使用 nextTick 以免获取不到 dom
+  await nextTick()
+  if (isIgnore || !isScrolling.value) {
+    messageContainer.value.scrollTop =
+      messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
+  }
 }
 
 function handleScroll() {

+ 5 - 7
src/views/ai/chat/index/components/role/RoleList.vue

@@ -10,15 +10,13 @@
                 <el-icon><More /></el-icon>
               </el-button>
             </span>
-            <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
             <template #dropdown>
               <el-dropdown-menu>
                 <el-dropdown-item :command="['edit', role]">
-                  <el-icon><EditPen /></el-icon>编辑
+                  <Icon icon="ep:edit" color="#787878" />编辑
                 </el-dropdown-item>
                 <el-dropdown-item :command="['delete', role]" style="color: red">
-                  <el-icon><Delete /></el-icon>
-                  <span>删除</span>
+                  <Icon icon="ep:delete" color="red" />删除
                 </el-dropdown-item>
               </el-dropdown-menu>
             </template>
@@ -43,9 +41,9 @@
 </template>
 
 <script setup lang="ts">
-import { ChatRoleVO } from '@/api/ai/model/chatRole'
-import { PropType, ref } from 'vue'
-import { Delete, EditPen, More } from '@element-plus/icons-vue'
+import {ChatRoleVO} from '@/api/ai/model/chatRole'
+import {PropType, ref} from 'vue'
+import {More} from '@element-plus/icons-vue'
 
 const tabsRef = ref<any>() // tabs ref
 

+ 8 - 12
src/views/ai/chat/index/components/role/RoleRepository.vue

@@ -23,10 +23,7 @@
           @click="handlerAddRole"
           class="ml-20px"
         >
-          <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
-          <el-icon>
-            <User />
-          </el-icon>
+          <Icon icon="ep:user" style="margin-right: 5px;" />
           添加角色
         </el-button>
       </div>
@@ -67,15 +64,15 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import {ref} from 'vue'
 import RoleHeader from './RoleHeader.vue'
 import RoleList from './RoleList.vue'
 import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
 import RoleCategoryList from './RoleCategoryList.vue'
-import { ChatRoleApi, ChatRolePageReqVO, ChatRoleVO } from '@/api/ai/model/chatRole'
-import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
-import { Search, User } from '@element-plus/icons-vue'
-import { TabsPaneContext } from 'element-plus'
+import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole'
+import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
+import {Search} from '@element-plus/icons-vue'
+import {TabsPaneContext} from 'element-plus'
 
 const router = useRouter() // 路由对象
 
@@ -222,15 +219,14 @@ onMounted(async () => {
   // 获取 role 数据
   await getActiveTabsRole()
 })
-// TODO @fan:css 是不是可以融合到 scss 里面呀?
 </script>
-<style lang="css">
+<!-- 覆盖 element ui css -->
+<style lang="scss">
 .el-tabs__content {
   position: relative;
   height: 100%;
   overflow: hidden;
 }
-
 .el-tabs__nav-scroll {
   margin: 10px 20px;
 }

+ 15 - 16
src/views/ai/chat/index/index.vue

@@ -22,11 +22,14 @@
             <Icon icon="ep:setting" class="ml-10px" />
           </el-button>
           <el-button size="small" class="btn" @click="handlerMessageClear">
-            <img src="@/assets/ai/clear.svg" class="h-14px" />
+            <Icon icon="heroicons-outline:archive-box-x-mark" color="#787878" />
+          </el-button>
+          <el-button size="small" class="btn">
+            <Icon icon="ep:download" color="#787878" />
+          </el-button>
+          <el-button size="small" class="btn" @click="handleGoTopMessage" >
+            <Icon icon="ep:top" color="#787878" />
           </el-button>
-          <!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
-          <el-button size="small" :icon="Download" class="btn" />
-          <el-button size="small" :icon="Top" class="btn" @click="handleGoTopMessage" />
         </div>
       </el-header>
 
@@ -180,11 +183,6 @@ const handleConversationClick = async (conversation: ChatConversationVO) => {
   // 更新选中的对话 id
   activeConversationId.value = conversation.id
   activeConversation.value = conversation
-  // 处理进行中的对话
-  // TODO @fan:这里,和上面的 “对话进行中,不允许切换” 是不是重叠了?
-  if (conversationInProgress.value) {
-    await stopStream()
-  }
   // 刷新 message 列表
   await getMessageList()
   // 滚动底部
@@ -203,7 +201,11 @@ const handlerConversationDelete = async (delConversation: ChatConversationVO) =>
 }
 /** 清空选中的对话 */
 const handleConversationClear = async () => {
-  // TODO @fan:需要加一个 对话进行中,不允许切换
+  // 对话进行中,不允许切换
+  if (conversationInProgress.value) {
+    message.alert('对话中,不允许切换!')
+    return false
+  }
   activeConversationId.value = null
   activeConversation.value = null
   activeMessageList.value = []
@@ -363,7 +365,7 @@ const handlePromptInput = (event) => {
     isComposing.value = false
   }, 400)
 }
-// TODO @fan:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
+// TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
 const onCompositionstart = () => {
   isComposing.value = true
 }
@@ -394,7 +396,6 @@ const doSendMessage = async (content: string) => {
   } as ChatMessageVO)
 }
 
-// TODO @fan:= = 不知道哪里被改动了。点击【发送】后,不会跳转到消息最底部了。。
 /** 真正执行【发送】消息操作 */
 const doSendMessageStream = async (userMessage: ChatMessageVO) => {
   // 创建 AbortController 实例,以便中止请求
@@ -421,9 +422,8 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
       createTime: new Date()
     } as ChatMessageVO)
     // 1.2 滚动到最下面
-    nextTick(async () => {
-      await scrollToBottom() // 底部
-    })
+    await nextTick()
+    await scrollToBottom() // 底部
     // 1.3 开始滚动
     textRoll()
 
@@ -573,7 +573,6 @@ onMounted(async () => {
 
 <style lang="scss" scoped>
 .ai-layout {
-  // TODO @范 这里height不能 100% 先这样临时处理 TODO @fan:这个目前要搞处理么?
   position: absolute;
   flex: 1;
   top: 0;

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

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

+ 52 - 54
src/views/ai/image/index/ImageTaskCard.vue → src/views/ai/image/index/components/ImageCard.vue

@@ -2,58 +2,53 @@
   <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 type="primary" text bg v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS">
           生成中
         </el-button>
-        <el-button text bg v-else-if="imageDetail?.status === AiImageStatusEnum.SUCCESS">
+        <el-button text bg v-else-if="detail?.status === AiImageStatusEnum.SUCCESS">
           已完成
         </el-button>
-        <el-button type="danger" text bg v-else-if="imageDetail?.status === AiImageStatusEnum.FAIL">
+        <el-button type="danger" text bg v-else-if="detail?.status === AiImageStatusEnum.FAIL">
           异常
         </el-button>
       </div>
+      <!-- 操作区 -->
       <div>
         <el-button
           class="btn"
           text
           :icon="Download"
-          @click="handleBtnClick('download', imageDetail)"
+          @click="handleButtonClick('download', detail)"
         />
         <el-button
           class="btn"
           text
           :icon="RefreshRight"
-          @click="handleBtnClick('regeneration', imageDetail)"
+          @click="handleButtonClick('regeneration', detail)"
         />
-        <el-button
-          class="btn"
-          text
-          :icon="Delete"
-          @click="handleBtnClick('delete', imageDetail)"
-        />
-        <el-button class="btn" text :icon="More" @click="handleBtnClick('more', imageDetail)" />
+        <el-button class="btn" text :icon="Delete" @click="handleButtonClick('delete', detail)" />
+        <el-button class="btn" text :icon="More" @click="handleButtonClick('more', detail)" />
       </div>
     </div>
     <div class="image-wrapper" ref="cardImageRef">
-      <!-- TODO @fan:要不加个点击,大图预览? -->
-      <img class="image" :src="imageDetail?.picUrl" />
-      <div v-if="imageDetail?.status === AiImageStatusEnum.FAIL">
-        {{ imageDetail?.errorMessage }}
+      <el-image
+        class="image"
+        :src="detail?.picUrl"
+        :preview-src-list="[detail.picUrl]"
+        preview-teleported
+      />
+      <div v-if="detail?.status === AiImageStatusEnum.FAIL">
+        {{ detail?.errorMessage }}
       </div>
     </div>
-    <!-- TODO @fan:style 使用 unocss 替代下 -->
+    <!-- Midjourney 专属操作 -->
     <div class="image-mj-btns">
       <el-button
         size="small"
-        v-for="button in imageDetail?.buttons"
+        v-for="button in detail?.buttons"
         :key="button"
-        style="min-width: 40px; margin-left: 0; margin-right: 10px; margin-top: 5px"
-        @click="handleMjBtnClick(button)"
+        class="min-w-40px ml-0 mr-10px mt-5px"
+        @click="handleMidjourneyBtnClick(button)"
       >
         {{ button.label }}{{ button.emoji }}
       </el-button>
@@ -61,34 +56,53 @@
   </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 { Delete, Download, More, RefreshRight } from '@element-plus/icons-vue'
+import { ImageVO, ImageMidjourneyButtonsVO } from '@/api/ai/image'
 import { PropType } from 'vue'
-import {ElLoading, LoadingOptionsResolved} from 'element-plus'
+import { 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 message = useMessage() // 消息
+
 const props = defineProps({
-  imageDetail: {
+  detail: {
     type: Object as PropType<ImageVO>,
     require: true
   }
 })
 
-/**  按钮 - 点击事件  */
-const handleBtnClick = async (type, imageDetail: ImageVO) => {
-  emits('onBtnClick', type, imageDetail)
+const cardImageRef = ref<any>() // 卡片 image ref
+const cardImageLoadingInstance = ref<any>() // 卡片 image ref
+
+/** 处理点击事件  */
+const handleButtonClick = async (type, detail: ImageVO) => {
+  emits('onBtnClick', type, detail)
+}
+
+/** 处理 Midjourney 按钮点击事件  */
+const handleMidjourneyBtnClick = async (button: ImageMidjourneyButtonsVO) => {
+  // 确认窗体
+  await message.confirm(`确认操作 "${button.label} ${button.emoji}" ?`)
+  emits('onMjBtnClick', button, props.detail)
 }
 
+const emits = defineEmits(['onBtnClick', 'onMjBtnClick']) // emits
+
+/** 监听详情 */
+const { detail } = toRefs(props)
+watch(detail, async (newVal, oldVal) => {
+  await handleLoading(newVal.status as string)
+})
+
+/** 处理加载状态 */
 const handleLoading = async (status: number) => {
-  // TODO @芋艿:这个搞成 Loading 组件,然后通过数据驱动,这样搞可以哇?
+  // 情况一:如果是生成中,则设置加载中的 loading
   if (status === AiImageStatusEnum.IN_PROGRESS) {
     cardImageLoadingInstance.value = ElLoading.service({
       target: cardImageRef.value,
       text: '生成中...'
     } as LoadingOptionsResolved)
+    // 情况二:如果已经生成结束,则移除 loading
   } else {
     if (cardImageLoadingInstance.value) {
       cardImageLoadingInstance.value.close()
@@ -97,25 +111,9 @@ const handleLoading = async (status: number) => {
   }
 }
 
-/**  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)
+  await handleLoading(props.detail.status as string)
 })
 </script>
 

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

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

+ 90 - 86
src/views/ai/image/index/ImageTask.vue → src/views/ai/image/index/components/ImageList.vue

@@ -1,85 +1,87 @@
 <template>
   <el-card class="dr-task" body-class="task-card" shadow="never">
     <template #header>绘画任务</template>
-    <div class="task-image-list" ref="imageTaskRef">
-      <ImageTaskCard
+    <!-- 图片列表 -->
+    <div class="task-image-list" ref="imageListRef">
+      <ImageCard
         v-for="image in imageList"
-        :key="image"
-        :image-detail="image"
-        @on-btn-click="handleImageBtnClick"
-        @on-mj-btn-click="handleImageMjBtnClick"
+        :key="image.id"
+        :detail="image"
+        @on-btn-click="handleImageButtonClick"
+        @on-mj-btn-click="handleImageMidjourneyButtonClick"
       />
     </div>
     <div class="task-image-pagination">
-      <el-pagination
-        background
-        layout="prev, pager, next"
-        :default-page-size="pageSize"
+      <Pagination
         :total="pageTotal"
-        @change="handlePageChange"
+        v-model:page="queryParams.pageNo"
+        v-model:limit="queryParams.pageSize"
+        @pagination="getImageList"
       />
     </div>
   </el-card>
-  <!-- 图片 detail 抽屉 -->
-  <ImageDetailDrawer
+
+  <!-- 图片详情 -->
+  <ImageDetail
     :show="isShowImageDetail"
     :id="showImageDetailId"
-    @handle-drawer-close="handleDrawerClose"
+    @handle-drawer-close="handleDetailClose"
   />
 </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 {
+  ImageApi,
+  ImageVO,
+  ImageMidjourneyActionVO,
+  ImageMidjourneyButtonsVO
+} from '@/api/ai/image'
+import ImageDetail from './ImageDetail.vue'
+import ImageCard from './ImageCard.vue'
 import { ElLoading, LoadingOptionsResolved } from 'element-plus'
 import { AiImageStatusEnum } from '@/views/ai/utils/constants'
-import { downloadImage } from '@/utils/download'
+import download from '@/utils/download'
 
 const message = useMessage() // 消息弹窗
 
+// 图片分页相关的参数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10
+})
+const pageTotal = ref<number>(0) // page size
 const imageList = ref<ImageVO[]>([]) // image 列表
+const imageListLoadingInstance = ref<any>() // image 列表是否正在加载中
+const imageListRef = ref<any>() // ref
+// 图片轮询相关的参数(正在生成中的)
 const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
-const 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
+const inProgressTimer = ref<any>() // 生成中的 image 定时器,轮询生成进展
+// 图片详情相关的参数
+const isShowImageDetail = ref<boolean>(false) // 图片详情是否展示
+const showImageDetailId = ref<number>(0) // 图片详情的图片编号
 
-/**  抽屉 - close  */
-const handleDrawerClose = async () => {
-  isShowImageDetail.value = false
+/** 查看图片的详情  */
+const handleDetailOpen = async () => {
+  isShowImageDetail.value = true
 }
 
-/**  任务 - detail  */
-const handleDrawerOpen = async () => {
-  isShowImageDetail.value = true
+/** 关闭图片的详情  */
+const handleDetailClose = async () => {
+  isShowImageDetail.value = false
 }
 
-/**
- * 获取 - image 列表
- */
-const getImageList = async (apply: boolean = false) => {
-  imageTaskLoading.value = true
+/** 获得 image 图片列表 */
+const getImageList = async () => {
   try {
-    imageTaskLoadingInstance.value = ElLoading.service({
-      target: imageTaskRef.value,
+    // 1. 加载图片列表
+    imageListLoadingInstance.value = ElLoading.service({
+      target: imageListRef.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
-    }
+    const { list, total } = await ImageApi.getImagePageMy(queryParams)
+    imageList.value = list
     pageTotal.value = total
-    // 需要 watch 的数据
+
+    // 2. 计算需要轮询的图片
     const newWatImages = {}
     imageList.value.forEach((item) => {
       if (item.status === AiImageStatusEnum.IN_PROGRESS) {
@@ -88,9 +90,10 @@ const getImageList = async (apply: boolean = false) => {
     })
     inProgressImageMap.value = newWatImages
   } finally {
-    if (imageTaskLoadingInstance.value) {
-      imageTaskLoadingInstance.value.close()
-      imageTaskLoadingInstance.value = null
+    // 关闭正在“加载中”的 Loading
+    if (imageListLoadingInstance.value) {
+      imageListLoadingInstance.value.close()
+      imageListLoadingInstance.value = null
     }
   }
 }
@@ -117,50 +120,52 @@ const refreshWatchImages = async () => {
   inProgressImageMap.value = newWatchImages
 }
 
-/**  图片 - btn click  */
-const handleImageBtnClick = async (type: string, imageDetail: ImageVO) => {
-  // 获取 image detail id
-  showImageDetailId.value = imageDetail.id
-  // 处理不用 btn
+/** 图片的点击事件 */
+const handleImageButtonClick = async (type: string, imageDetail: ImageVO) => {
+  // 详情
   if (type === 'more') {
-    await handleDrawerOpen()
-  } else if (type === 'delete') {
+    showImageDetailId.value = imageDetail.id
+    await handleDetailOpen()
+    return
+  }
+  // 删除
+  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)
+    return
+  }
+  // 下载
+  if (type === 'download') {
+    await download.image(imageDetail.picUrl)
+    return
+  }
+  // 重新生成
+  if (type === 'regeneration') {
     await emits('onRegeneration', imageDetail)
+    return
   }
 }
 
-/**  图片 - mj btn click  */
-const handleImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageVO) => {
-  // 1、构建 params 参数
+/** 处理 Midjourney 按钮点击事件  */
+const handleImageMidjourneyButtonClick = async (
+  button: ImageMidjourneyButtonsVO,
+  imageDetail: ImageVO
+) => {
+  // 1. 构建 params 参数
   const data = {
     id: imageDetail.id,
     customId: button.customId
-  } as ImageMjActionVO
-  // 2发送 action
+  } as ImageMidjourneyActionVO
+  // 2. 发送 action
   await ImageApi.midjourneyAction(data)
-  // 3刷新列表
+  // 3. 刷新列表
   await getImageList()
 }
 
-// page change
-const handlePageChange = async (page) => {
-  pageNo.value = page
-  await getImageList(false)
-}
+defineExpose({ getImageList }) // 暴露组件方法
 
-/** 暴露组件方法 */
-defineExpose({ getImageList })
-
-// emits
 const emits = defineEmits(['onRegeneration'])
 
 /** 组件挂在的时候 */
@@ -168,19 +173,20 @@ onMounted(async () => {
   // 获取 image 列表
   await getImageList()
   // 自动刷新 image 列表
-  imageListInterval.value = setInterval(async () => {
+  inProgressTimer.value = setInterval(async () => {
     await refreshWatchImages()
   }, 1000 * 3)
 })
 
 /** 组件取消挂在的时候 */
 onUnmounted(async () => {
-  if (imageListInterval.value) {
-    clearInterval(imageListInterval.value)
+  if (inProgressTimer.value) {
+    clearInterval(inProgressTimer.value)
   }
 })
 </script>
 
+<!-- TODO fan:这 2 个 scss 可以合并么? -->
 <style lang="scss">
 .task-card {
   margin: 0;
@@ -197,8 +203,7 @@ onUnmounted(async () => {
   align-content: flex-start;
   height: 100%;
   overflow: auto;
-  padding: 20px;
-  padding-bottom: 140px;
+  padding: 20px 20px 140px;
   box-sizing: border-box; /* 确保内边距不会增加高度 */
 
   > div {
@@ -224,7 +229,6 @@ onUnmounted(async () => {
   align-items: center;
 }
 </style>
-
 <style scoped lang="scss">
 .dr-task {
   width: 100%;

+ 63 - 136
src/views/ai/image/index/dall3/index.vue → src/views/ai/image/index/components/dall3/index.vue

@@ -3,12 +3,11 @@
   <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;"
+      class="w-100% mt-15px"
       input-style="border-radius: 7px;"
       placeholder="例如:童话里的小屋应该是什么样子?"
       show-word-limit
@@ -20,12 +19,13 @@
       <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)"
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in ImageHotWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
       >
         {{ hotWord }}
       </el-button>
@@ -38,16 +38,11 @@
     <el-space wrap class="model-list">
       <div
         :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
-        v-for="model in models"
+        v-for="model in Dall3Models"
         :key="model.key"
-
       >
-        <el-image
-          :src="model.image"
-          fit="contain"
-          @click="handleModelClick(model)"
-        />
-        <div class="model-font">{{model.name}}</div>
+        <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
+        <div class="model-font">{{ model.name }}</div>
       </div>
     </el-space>
   </div>
@@ -57,16 +52,12 @@
     </div>
     <el-space wrap class="image-style-list">
       <div
-        :class="selectImageStyle === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'"
-        v-for="imageStyle in imageStyleList"
+        :class="style === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'"
+        v-for="imageStyle in Dall3StyleList"
         :key="imageStyle.key"
       >
-        <el-image
-          :src="imageStyle.image"
-          fit="contain"
-          @click="handleStyleClick(imageStyle)"
-        />
-        <div class="style-font">{{imageStyle.name}}</div>
+        <el-image :src="imageStyle.image" fit="contain" @click="handleStyleClick(imageStyle)" />
+        <div class="style-font">{{ imageStyle.name }}</div>
       </div>
     </el-space>
   </div>
@@ -75,11 +66,15 @@
       <el-text tag="b">画面比例</el-text>
     </div>
     <el-space wrap class="size-list">
-      <div class="size-item"
-           v-for="imageSize in imageSizeList"
-           :key="imageSize.key"
-           @click="handleSizeClick(imageSize)">
-        <div :class="selectImageSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'">
+      <div
+        class="size-item"
+        v-for="imageSize in Dall3SizeList"
+        :key="imageSize.key"
+        @click="handleSizeClick(imageSize)"
+      >
+        <div
+          :class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
+        >
           <div :style="imageSize.style"></div>
         </div>
         <div class="size-font">{{ imageSize.name }}</div>
@@ -87,126 +82,60 @@
     </el-space>
   </div>
   <div class="btns">
-    <el-button type="primary"
-               size="large"
-               round
-               :loading="drawIn"
-               @click="handleGenerateImage">
-      {{drawIn ? '生成中' : '生成内容'}}
+    <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';
-
-// image 模型
-interface ImageModelVO {
-  key: string
-  name: string
-  image: string
-}
-
-// image 大小
-interface ImageSizeVO {
-  key: string
-  name: string,
-  style: string,
-  width: string,
-  height: string,
-}
+import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
+import {
+  Dall3Models,
+  Dall3StyleList,
+  ImageHotWords,
+  Dall3SizeList,
+  ImageModelVO,
+  AiPlatformEnum
+} from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息弹窗
 
 // 定义属性
-const prompt = ref<string>('')  // 提示词
-const drawIn = ref<boolean>(false)  // 生成中
+const prompt = ref<string>('') // 提示词
+const drawIn = ref<boolean>(false) // 生成中
 const selectHotWord = ref<string>('') // 选中的热词
-const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
 const selectModel = ref<string>('dall-e-3') // 模型
-// message
-const message = useMessage()
-const models = ref<ImageModelVO[]>([
-  {
-    key: 'dall-e-3',
-    name: 'DALL·E 3',
-    image: `/src/assets/ai/dall2.jpg`,
-  },
-  {
-    key: 'dall-e-2',
-    name: 'DALL·E 2',
-    image: `/src/assets/ai/dall3.jpg`,
-  },
-])  // 模型
+const selectSize = ref<string>('1024x1024') // 选中 size
+const style = ref<string>('vivid') // style 样式
 
-const selectImageStyle = ref<string>('vivid') // style 样式
+const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
 
-const imageStyleList = ref<ImageModelVO[]>([
-  {
-    key: 'vivid',
-    name: '清晰',
-    image: `/src/assets/ai/qingxi.jpg`,
-  },
-  {
-    key: 'natural',
-    name: '自然',
-    image: `/src/assets/ai/ziran.jpg`,
-  },
-])  // style
-
-const selectImageSize = ref<string>('1024x1024') // 选中 size
-const imageSizeList = ref<ImageSizeVO[]>([
-  {
-    key: '1024x1024',
-    name: '1:1',
-    width: '1024',
-    height: '1024',
-    style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
-  },
-  {
-    key: '1024x1792',
-    name: '3:5',
-    width: '1024',
-    height: '1792',
-    style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
-  },
-  {
-    key: '1792x1024',
-    name: '5:3',
-    width: '1792',
-    height: '1024',
-    style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
-  }
-]) // size
-
-// 定义 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
 }
 
-/**  模型 - click  */
+/** 选择 model 模型 */
 const handleModelClick = async (model: ImageModelVO) => {
   selectModel.value = model.key
 }
 
-/**  样式 - click  */
+/** 选择 style 样式  */
 const handleStyleClick = async (imageStyle: ImageModelVO) => {
-  selectImageStyle.value = imageStyle.key
+  style.value = imageStyle.key
 }
 
-/**  size - click  */
+/** 选择 size 大小  */
 const handleSizeClick = async (imageSize: ImageSizeVO) => {
-  selectImageSize.value = imageSize.key
+  selectSize.value = imageSize.key
 }
 
 /**  图片生产  */
@@ -217,42 +146,41 @@ const handleGenerateImage = async () => {
     // 加载中
     drawIn.value = true
     // 回调
-    emits('onDrawStart', selectModel.value)
-    const imageSize = imageSizeList.value.find(item => item.key === selectImageSize.value) as ImageSizeVO
+    emits('onDrawStart', AiPlatformEnum.OPENAI)
+    const imageSize = Dall3SizeList.find((item) => item.key === selectSize.value) as ImageSizeVO
     const form = {
-      platform: 'OpenAI',
+      platform: AiPlatformEnum.OPENAI,
       prompt: prompt.value, // 提示词
       model: selectModel.value, // 模型
       width: imageSize.width, // size 不能为空
       height: imageSize.height, // size 不能为空
       options: {
-        style: selectImageStyle.value, // 图像生成的风格
+        style: style.value // 图像生成的风格
       }
     } as ImageDrawReqVO
     // 发送请求
     await ImageApi.drawImage(form)
   } finally {
     // 回调
-    emits('onDrawComplete', selectModel.value)
+    emits('onDrawComplete', AiPlatformEnum.OPENAI)
     // 加载结束
     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
+const settingValues = async (detail: ImageVO) => {
+  prompt.value = detail.prompt
+  selectModel.value = detail.model
+  style.value = detail.options?.style
+  const imageSize = Dall3SizeList.find(
+    (item) => item.key === `${detail.width}x${detail.height}`
+  ) as ImageSizeVO
   await handleSizeClick(imageSize)
 }
 
 /** 暴露组件方法 */
 defineExpose({ settingValues })
-
 </script>
 <style scoped lang="scss">
 // 提示词
@@ -309,7 +237,6 @@ defineExpose({ settingValues })
   }
 }
 
-
 // 样式 style
 .image-style {
   margin-top: 30px;

+ 91 - 172
src/views/ai/image/index/midjourney/index.vue → src/views/ai/image/index/components/midjourney/index.vue

@@ -7,7 +7,7 @@
       v-model="prompt"
       maxlength="1024"
       rows="5"
-      style="width: 100%; margin-top: 15px;"
+      class="w-100% mt-15px"
       input-style="border-radius: 7px;"
       placeholder="例如:童话里的小屋应该是什么样子?"
       show-word-limit
@@ -19,12 +19,13 @@
       <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)"
+      <el-button
+        round
+        class="btn"
+        :type="selectHotWord === hotWord ? 'primary' : 'default'"
+        v-for="hotWord in ImageHotWords"
+        :key="hotWord"
+        @click="handleHotWordClick(hotWord)"
       >
         {{ hotWord }}
       </el-button>
@@ -35,17 +36,36 @@
       <el-text tag="b">尺寸</el-text>
     </div>
     <el-space wrap class="size-list">
-      <div class="size-item"
-           v-for="imageSize in imageSizeList"
-           :key="imageSize.key"
-           @click="handleSizeClick(imageSize)">
-        <div :class="selectImageSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'">
+      <div
+        class="size-item"
+        v-for="imageSize in MidjourneySizeList"
+        :key="imageSize.key"
+        @click="handleSizeClick(imageSize)"
+      >
+        <div
+          :class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
+        >
           <div :style="imageSize.style"></div>
         </div>
         <div class="size-font">{{ imageSize.key }}</div>
       </div>
     </el-space>
   </div>
+  <div class="model">
+    <div>
+      <el-text tag="b">模型</el-text>
+    </div>
+    <el-space wrap class="model-list">
+      <div
+        :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
+        v-for="model in MidjourneyModels"
+        :key="model.key"
+      >
+        <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
+        <div class="model-font">{{ model.name }}</div>
+      </div>
+    </el-space>
+  </div>
   <div class="version">
     <div>
       <el-text tag="b">版本</el-text>
@@ -53,11 +73,9 @@
     <el-space wrap class="version-list">
       <el-select
         v-model="selectVersion"
-        class="version-select"
+        class="version-select !w-350px"
         clearable
         placeholder="请选择版本"
-        style="width: 350px"
-        @change="handleChangeVersion"
       >
         <el-option
           v-for="item in versionList"
@@ -68,228 +86,130 @@
       </el-select>
     </el-space>
   </div>
-  <div class="model">
-    <div>
-      <el-text tag="b">模型</el-text>
-    </div>
-    <el-space wrap class="model-list">
-      <div
-        :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
-        v-for="model in models"
-        :key="model.key"
-
-      >
-        <el-image
-          :src="model.image"
-          fit="contain"
-          @click="handleModelClick(model)"
-        />
-        <div class="model-font">{{model.name}}</div>
-      </div>
-    </el-space>
-  </div>
   <div class="model">
     <div>
       <el-text tag="b">参考图</el-text>
     </div>
     <el-space wrap class="model-list">
-      <UploadImg v-model="referImage" height="80px" width="80px" />
+      <UploadImg v-model="referImageUrl" height="120px" width="120px" />
     </el-space>
   </div>
   <div class="btns">
-    <!--    <el-button size="large" round>重置内容</el-button>-->
-    <el-button type="primary" size="large" round @click="handleGenerateImage">生成内容</el-button>
+    <el-button type="primary" size="large" round @click="handleGenerateImage">
+      {{ drawIn ? '生成中' : '生成内容' }}
+    </el-button>
   </div>
 </template>
 <script setup lang="ts">
-
-// image 模型
-import {ImageApi, ImageMidjourneyImagineReqVO, ImageVO} from "@/api/ai/image";
-// message
-const message = useMessage()
-// 定义 emits
-const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
-
-interface ImageModelVO {
-  key: string
-  name: string
-  image: string
-}
-
-// image 大小
-interface ImageSizeVO {
-  key: string
-  style: string,
-  width: string,
-  height: string,
-}
+import { ImageApi, ImageMidjourneyImagineReqVO, ImageVO } from '@/api/ai/image'
+import {
+  AiPlatformEnum,
+  ImageHotWords,
+  ImageSizeVO,
+  ImageModelVO,
+  MidjourneyModels,
+  MidjourneySizeList,
+  MidjourneyVersions,
+  NijiVersionList
+} from '@/views/ai/utils/constants'
+
+const message = useMessage() // 消息弹窗
 
 // 定义属性
-const prompt = ref<string>('')  // 提示词
-const referImage = ref<any>()  // 参考图
+const drawIn = ref<boolean>(false) // 生成中
 const selectHotWord = ref<string>('') // 选中的热词
-const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城'])  // 热词
-const selectModel = ref<string>('midjourney') // 选中的热词
-const models = ref<ImageModelVO[]>([
-  {
-    key: 'midjourney',
-    name: 'MJ',
-    image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png',
-  },
-  {
-    key: 'niji',
-    name: 'NIJI',
-    image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png',
-  },
-])  // 模型
-
-const selectImageSize = ref<string>('1:1') // 选中 size
-const imageSizeList = ref<ImageSizeVO[]>([
-  {
-    key: '1:1',
-    width: "1",
-    height: "1",
-    style: 'width: 30px; height: 30px;background-color: #dcdcdc;',
-  },
-  {
-    key: '3:4',
-    width: "3",
-    height: "4",
-    style: 'width: 30px; height: 40px;background-color: #dcdcdc;',
-  },
-  {
-    key: '4:3',
-    width: "4",
-    height: "3",
-    style: 'width: 40px; height: 30px;background-color: #dcdcdc;',
-  },
-  {
-    key: '9:16',
-    width: "9",
-    height: "16",
-    style: 'width: 30px; height: 50px;background-color: #dcdcdc;',
-  },
-  {
-    key: '16:9',
-    width: "16",
-    height: "9",
-    style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
-  },
-]) // size
-
-// version
-const midjourneyVersionList = ref<any>([
-  {
-    value: '6.0',
-    label: 'v6.0',
-  },
-  {
-    value: '5.2',
-    label: 'v5.2',
-  },
-  {
-    value: '5.1',
-    label: 'v5.1',
-  },
-  {
-    value: '5.0',
-    label: 'v5.0',
-  },
-  {
-    value: '4.0',
-    label: 'v4.0',
-  },
-])
-const nijiVersionList = ref<any>([
-  {
-    value: '5',
-    label: 'v5',
-  },
-])
+// 表单
+const prompt = ref<string>('') // 提示词
+const referImageUrl = ref<any>() // 参考图
+const selectModel = ref<string>('midjourney') // 选中的模型
+const selectSize = ref<string>('1:1') // 选中 size
 const selectVersion = ref<any>('6.0') // 选中的 version
-let versionList = ref<any>([]) // version 列表
-versionList.value = midjourneyVersionList.value // 默认选择 midjourney
+const versionList = ref<any>(MidjourneyVersions) // version 列表
+const emits = defineEmits(['onDrawStart', 'onDrawComplete']) // 定义 emits
 
-/**  热词 - click  */
+/** 选择热词 */
 const handleHotWordClick = async (hotWord: string) => {
-  // 取消
+  // 情况一:取消选中
   if (selectHotWord.value == hotWord) {
     selectHotWord.value = ''
     return
   }
-  // 选中
-  selectHotWord.value = hotWord
-  // 设置提示次
-  prompt.value = hotWord
+
+  // 情况二:选中
+  selectHotWord.value = hotWord // 选中
+  prompt.value = hotWord // 设置提示次
 }
 
-/**  size - click  */
+/** 点击 size 尺寸 */
 const handleSizeClick = async (imageSize: ImageSizeVO) => {
-  selectImageSize.value = imageSize.key
+  selectSize.value = imageSize.key
 }
 
-/**  模型 - click  */
+/** 点击 model 模型 */
 const handleModelClick = async (model: ImageModelVO) => {
   selectModel.value = model.key
   if (model.key === 'niji') {
-    versionList.value = nijiVersionList.value // 默认选择 niji
+    versionList.value = NijiVersionList // 默认选择 niji
   } else {
-    versionList.value = midjourneyVersionList.value // 默认选择 midjourney
+    versionList.value = MidjourneyVersions // 默认选择 midjourney
   }
   selectVersion.value = versionList.value[0].value
 }
 
-/**  version - click  */
-const handleChangeVersion = async (version) => {
-  console.log('version', version)
-}
-
-/** 图片生产  */
+/** 图片生成 */
 const handleGenerateImage = async () => {
   // 二次确认
   await message.confirm(`确认生成内容?`)
-  // todo @芋艿 图片生产逻辑
   try {
+    // 加载中
+    drawIn.value = true
     // 回调
-    emits('onDrawStart', selectModel.value)
+    emits('onDrawStart', AiPlatformEnum.MIDJOURNEY)
     // 发送请求
-    const imageSize = imageSizeList.value.find(item => selectImageSize.value === item.key) as ImageSizeVO
+    const imageSize = MidjourneySizeList.find(
+      (item) => selectSize.value === item.key
+    ) as ImageSizeVO
     const req = {
       prompt: prompt.value,
       model: selectModel.value,
       width: imageSize.width,
       height: imageSize.height,
       version: selectVersion.value,
-      referImageUrl: referImage.value,
+      referImageUrl: referImageUrl.value
     } as ImageMidjourneyImagineReqVO
     await ImageApi.midjourneyImagine(req)
   } finally {
     // 回调
-    emits('onDrawComplete', selectModel.value)
+    emits('onDrawComplete', AiPlatformEnum.MIDJOURNEY)
+    // 加载结束
+    drawIn.value = false
   }
 }
 
 /** 填充值 */
-const settingValues = async (imageDetail: ImageVO) => {
+const settingValues = async (detail: ImageVO) => {
   // 提示词
-  prompt.value = imageDetail.prompt
+  prompt.value = detail.prompt
   // image size
-  const imageSize = imageSizeList.value.find(item => item.key === `${imageDetail.width}:${imageDetail.height}`) as ImageSizeVO
-  selectImageSize.value = imageSize.key
+  const imageSize = MidjourneySizeList.find(
+    (item) => item.key === `${detail.width}:${detail.height}`
+  ) as ImageSizeVO
+  selectSize.value = imageSize.key
   // 选中模型
-  const model = models.value.find(item => item.key === imageDetail.options?.model) as ImageModelVO
+  const model = MidjourneyModels.find((item) => item.key === detail.options?.model) as ImageModelVO
   await handleModelClick(model)
   // 版本
-  selectVersion.value = versionList.value.find(item => item.value === imageDetail.options?.version).value
+  selectVersion.value = versionList.value.find(
+    (item) => item.value === detail.options?.version
+  ).value
   // image
-  referImage.value = imageDetail.options.referImageUrl
+  referImageUrl.value = detail.options.referImageUrl
 }
 
 /** 暴露组件方法 */
 defineExpose({ settingValues })
 </script>
 <style scoped lang="scss">
-
 // 提示词
 .prompt {
 }
@@ -354,7 +274,6 @@ defineExpose({ settingValues })
   }
 }
 
-
 // 尺寸
 .image-size {
   width: 100%;

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

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

+ 26 - 39
src/views/ai/image/index/index.vue

@@ -12,10 +12,7 @@
           @on-draw-start="handleDrawStart"
           @on-draw-complete="handleDrawComplete"
         />
-        <Midjourney
-          v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY"
-          ref="midjourneyRef"
-        />
+        <Midjourney v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY" ref="midjourneyRef" />
         <StableDiffusion
           v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
           ref="stableDiffusionRef"
@@ -24,28 +21,26 @@
       </div>
     </div>
     <div class="main">
-      <ImageTask ref="imageTaskRef" @on-regeneration="handleRegeneration" />
+      <ImageList ref="imageListRef" @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 ImageList from './components/ImageList.vue'
 import { AiPlatformEnum } from '@/views/ai/utils/constants'
-import {ImageVO} from "@/api/ai/image";
-
+import { ImageVO } from '@/api/ai/image'
+import Dall3 from './components/dall3/index.vue'
+import Midjourney from './components/midjourney/index.vue'
+import StableDiffusion from './components/stableDiffusion/index.vue'
 
-const imageTaskRef = ref<any>() // image task ref
-const dall3Ref = ref<any>() // openai ref
+const imageListRef = ref<any>() // image 列表 ref
+const dall3Ref = ref<any>() // dall3(openai) ref
 const midjourneyRef = ref<any>() // midjourney ref
 const stableDiffusionRef = ref<any>() // stable diffusion ref
 
 // 定义属性
-const selectPlatform = ref('StableDiffusion')
+const selectPlatform = ref(AiPlatformEnum.MIDJOURNEY)
 const platformOptions = [
   {
     label: 'DALL3 绘画',
@@ -61,35 +56,27 @@ const platformOptions = [
   }
 ]
 
-/**  绘画 - start  */
-const handleDrawStart = async (type) => {
-}
+/** 绘画 start  */
+const handleDrawStart = async (platform: string) => {}
 
-/**  绘画 - complete  */
-const handleDrawComplete = async (type) => {
-  await imageTaskRef.value.getImageList()
+/** 绘画 complete */
+const handleDrawComplete = async (platform: string) => {
+  await imageListRef.value.getImageList()
 }
 
-/**  绘画 - 重新生成  */
-const handleRegeneration = async (imageDetail: ImageVO) => {
+/**  重新生成:将画图详情填充到对应平台  */
+const handleRegeneration = async (image: 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)
-    })
+  selectPlatform.value = image.platform
+  // 根据不同平台填充 image
+  await nextTick()
+  if (image.platform === AiPlatformEnum.MIDJOURNEY) {
+    midjourneyRef.value.settingValues(image)
+  } else if (image.platform === AiPlatformEnum.OPENAI) {
+    dall3Ref.value.settingValues(image)
+  } else if (image.platform === AiPlatformEnum.STABLE_DIFFUSION) {
+    stableDiffusionRef.value.settingValues(image)
   }
-
 }
 </script>
 

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

@@ -1,437 +0,0 @@
-<!-- dall3 -->
-<template>
-  <div class="prompt">
-    <el-text tag="b">画面描述</el-text>
-    <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
-    <!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
-    <el-input
-      v-model="prompt"
-      maxlength="1024"
-      rows="5"
-      style="width: 100%; margin-top: 15px"
-      input-style="border-radius: 7px;"
-      placeholder="例如:童话里的小屋应该是什么样子?"
-      show-word-limit
-      type="textarea"
-    />
-  </div>
-  <div class="hot-words">
-    <div>
-      <el-text tag="b">随机热词</el-text>
-    </div>
-    <el-space wrap class="word-list">
-      <el-button
-        round
-        class="btn"
-        :type="selectHotWord === hotWord ? 'primary' : 'default'"
-        v-for="hotWord in hotWords"
-        :key="hotWord"
-        @click="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>

+ 8 - 9
src/views/ai/model/chatRole/ChatRoleForm.vue

@@ -13,7 +13,7 @@
       <el-form-item label="角色头像" prop="avatar">
         <UploadImg v-model="formData.avatar" height="60px" width="60px" />
       </el-form-item>
-      <el-form-item label="绑定模型" prop="modelId" v-if="!isUser(formType)">
+      <el-form-item label="绑定模型" prop="modelId" v-if="!isUser">
         <el-select v-model="formData.modelId" placeholder="请选择模型" clearable>
           <el-option
             v-for="chatModel in chatModelList"
@@ -23,7 +23,7 @@
           />
         </el-select>
       </el-form-item>
-      <el-form-item label="角色类别" prop="category" v-if="!isUser(formType)">
+      <el-form-item label="角色类别" prop="category" v-if="!isUser">
         <el-input v-model="formData.category" placeholder="请输入角色类别" />
       </el-form-item>
       <el-form-item label="角色描述" prop="description">
@@ -32,7 +32,7 @@
       <el-form-item label="角色设定" prop="systemMessage">
         <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
       </el-form-item>
-      <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser(formType)">
+      <el-form-item label="是否公开" prop="publicStatus" v-if="!isUser">
         <el-radio-group v-model="formData.publicStatus">
           <el-radio
             v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
@@ -43,10 +43,10 @@
           </el-radio>
         </el-radio-group>
       </el-form-item>
-      <el-form-item label="角色排序" prop="sort" v-if="!isUser(formType)">
+      <el-form-item label="角色排序" prop="sort" v-if="!isUser">
         <el-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
       </el-form-item>
-      <el-form-item label="开启状态" prop="status" v-if="!isUser(formType)">
+      <el-form-item label="开启状态" prop="status" v-if="!isUser">
         <el-radio-group v-model="formData.status">
           <el-radio
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -97,10 +97,9 @@ const formRef = ref() // 表单 Ref
 const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
 
 /** 是否【我】自己创建,私有角色 */
-// TODO @fan:建议改成计算函数 computed
-const isUser = (type: string) => {
-  return type === 'my-create' || type === 'my-update'
-}
+const isUser = computed(() => {
+  return formType.value === 'my-create' || formType.value === 'my-update'
+})
 
 // TODO @fan:直接使用 formRules;只要隐藏掉的字段,它是不会校验的哈;
 const getFormRules = async (type: string) => {

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

@@ -48,3 +48,308 @@ export enum AiWriteTypeEnum {
   WRITING = 1, // 撰写
   REPLY // 回复
 }
+
+// ========== 【图片 UI】相关的枚举 ==========
+export const ImageHotWords = [
+  '中国旗袍',
+  '古装美女',
+  '卡通头像',
+  '机甲战士',
+  '童话小屋',
+  '中国长城'
+] // 图片热词
+
+export const ImageHotEnglishWords = [
+  'Chinese Cheongsam',
+  'Ancient Beauty',
+  'Cartoon Avatar',
+  'Mech Warrior',
+  'Fairy Tale Cottage',
+  'The Great Wall of China'
+] // 图片热词(英文)
+
+export interface ImageModelVO {
+  key: string
+  name: string
+  image?: string
+}
+
+export const StableDiffusionSamplers: ImageModelVO[] = [
+  {
+    key: 'DDIM',
+    name: 'DDIM'
+  },
+  {
+    key: 'DDPM',
+    name: 'DDPM'
+  },
+  {
+    key: 'K_DPMPP_2M',
+    name: 'K_DPMPP_2M'
+  },
+  {
+    key: 'K_DPMPP_2S_ANCESTRAL',
+    name: 'K_DPMPP_2S_ANCESTRAL'
+  },
+  {
+    key: 'K_DPM_2',
+    name: 'K_DPM_2'
+  },
+  {
+    key: 'K_DPM_2_ANCESTRAL',
+    name: 'K_DPM_2_ANCESTRAL'
+  },
+  {
+    key: 'K_EULER',
+    name: 'K_EULER'
+  },
+  {
+    key: 'K_EULER_ANCESTRAL',
+    name: 'K_EULER_ANCESTRAL'
+  },
+  {
+    key: 'K_HEUN',
+    name: 'K_HEUN'
+  },
+  {
+    key: 'K_LMS',
+    name: 'K_LMS'
+  }
+]
+
+export const StableDiffusionStylePresets: ImageModelVO[] = [
+  {
+    key: '3d-model',
+    name: '3d-model'
+  },
+  {
+    key: 'analog-film',
+    name: 'analog-film'
+  },
+  {
+    key: 'anime',
+    name: 'anime'
+  },
+  {
+    key: 'cinematic',
+    name: 'cinematic'
+  },
+  {
+    key: 'comic-book',
+    name: 'comic-book'
+  },
+  {
+    key: 'digital-art',
+    name: 'digital-art'
+  },
+  {
+    key: 'enhance',
+    name: 'enhance'
+  },
+  {
+    key: 'fantasy-art',
+    name: 'fantasy-art'
+  },
+  {
+    key: 'isometric',
+    name: 'isometric'
+  },
+  {
+    key: 'line-art',
+    name: 'line-art'
+  },
+  {
+    key: 'low-poly',
+    name: 'low-poly'
+  },
+  {
+    key: 'modeling-compound',
+    name: 'modeling-compound'
+  },
+  // neon-punk origami photographic pixel-art tile-texture
+  {
+    key: 'neon-punk',
+    name: 'neon-punk'
+  },
+  {
+    key: 'origami',
+    name: 'origami'
+  },
+  {
+    key: 'photographic',
+    name: 'photographic'
+  },
+  {
+    key: 'pixel-art',
+    name: 'pixel-art'
+  },
+  {
+    key: 'tile-texture',
+    name: 'tile-texture'
+  }
+]
+
+export const StableDiffusionClipGuidancePresets: ImageModelVO[] = [
+  {
+    key: 'NONE',
+    name: 'NONE'
+  },
+  {
+    key: 'FAST_BLUE',
+    name: 'FAST_BLUE'
+  },
+  {
+    key: 'FAST_GREEN',
+    name: 'FAST_GREEN'
+  },
+  {
+    key: 'SIMPLE',
+    name: 'SIMPLE'
+  },
+  {
+    key: 'SLOW',
+    name: 'SLOW'
+  },
+  {
+    key: 'SLOWER',
+    name: 'SLOWER'
+  },
+  {
+    key: 'SLOWEST',
+    name: 'SLOWEST'
+  }
+]
+
+export const Dall3Models: ImageModelVO[] = [
+  {
+    key: 'dall-e-3',
+    name: 'DALL·E 3',
+    image: `/src/assets/ai/dall2.jpg`
+  },
+  {
+    key: 'dall-e-2',
+    name: 'DALL·E 2',
+    image: `/src/assets/ai/dall3.jpg`
+  }
+]
+
+export const Dall3StyleList: ImageModelVO[] = [
+  {
+    key: 'vivid',
+    name: '清晰',
+    image: `/src/assets/ai/qingxi.jpg`
+  },
+  {
+    key: 'natural',
+    name: '自然',
+    image: `/src/assets/ai/ziran.jpg`
+  }
+]
+
+export interface ImageSizeVO {
+  key: string
+  name: string
+  style: string
+  width: string
+  height: string
+}
+
+export const Dall3SizeList: ImageSizeVO[] = [
+  {
+    key: '1024x1024',
+    name: '1:1',
+    width: '1024',
+    height: '1024',
+    style: 'width: 30px; height: 30px;background-color: #dcdcdc;'
+  },
+  {
+    key: '1024x1792',
+    name: '3:5',
+    width: '1024',
+    height: '1792',
+    style: 'width: 30px; height: 50px;background-color: #dcdcdc;'
+  },
+  {
+    key: '1792x1024',
+    name: '5:3',
+    width: '1792',
+    height: '1024',
+    style: 'width: 50px; height: 30px;background-color: #dcdcdc;'
+  }
+]
+
+export const MidjourneyModels: ImageModelVO[] = [
+  {
+    key: 'midjourney',
+    name: 'MJ',
+    image: 'https://bigpt8.com/pc/_nuxt/mj.34a61377.png'
+  },
+  {
+    key: 'niji',
+    name: 'NIJI',
+    image: 'https://bigpt8.com/pc/_nuxt/nj.ca79b143.png'
+  }
+]
+
+export const MidjourneySizeList: ImageSizeVO[] = [
+  {
+    key: '1:1',
+    width: '1',
+    height: '1',
+    style: 'width: 30px; height: 30px;background-color: #dcdcdc;'
+  },
+  {
+    key: '3:4',
+    width: '3',
+    height: '4',
+    style: 'width: 30px; height: 40px;background-color: #dcdcdc;'
+  },
+  {
+    key: '4:3',
+    width: '4',
+    height: '3',
+    style: 'width: 40px; height: 30px;background-color: #dcdcdc;'
+  },
+  {
+    key: '9:16',
+    width: '9',
+    height: '16',
+    style: 'width: 30px; height: 50px;background-color: #dcdcdc;'
+  },
+  {
+    key: '16:9',
+    width: '16',
+    height: '9',
+    style: 'width: 50px; height: 30px;background-color: #dcdcdc;'
+  }
+]
+
+export const MidjourneyVersions = [
+  {
+    value: '6.0',
+    label: 'v6.0'
+  },
+  {
+    value: '5.2',
+    label: 'v5.2'
+  },
+  {
+    value: '5.1',
+    label: 'v5.1'
+  },
+  {
+    value: '5.0',
+    label: 'v5.0'
+  },
+  {
+    value: '4.0',
+    label: 'v4.0'
+  }
+]
+
+export const NijiVersionList = [
+  {
+    value: '5',
+    label: 'v5'
+  }
+]

+ 1 - 1
src/views/ai/utils/utils.ts

@@ -8,7 +8,7 @@
  */
 
 /**  判断字符串是否包含中文  */
-export const hasChinese = async (str) => {
+export const hasChinese = (str: string) => {
   return /[\u4e00-\u9fa5]/.test(str)
 }
 

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

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

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

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

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

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

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

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

+ 114 - 14
src/views/mall/promotion/kefu/components/KeFuChatBox.vue

@@ -4,9 +4,16 @@
       <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
+        v-show="loadingMore"
+        class="loadingMore flex justify-center items-center cursor-pointer"
+        @click="handleOldMessage"
+      >
+        加载更多
+      </div>
+      <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)" @scroll="handleScroll">
         <div ref="innerRef" class="w-[100%] pb-3px">
-          <div v-for="(item, index) in messageList" :key="item.id" class="w-[100%]">
+          <div v-for="(item, index) in getMessageList0" :key="item.id" class="w-[100%]">
             <div class="flex justify-center items-center mb-20px">
               <!-- 日期 -->
               <div
@@ -48,6 +55,10 @@
                 <TextMessageItem :message="item" />
                 <!-- 图片消息 -->
                 <ImageMessageItem :message="item" />
+                <!-- 商品消息 -->
+                <ProductMessageItem :message="item" />
+                <!-- 订单消息 -->
+                <OrderMessageItem :message="item" />
               </div>
               <el-avatar
                 v-if="item.senderType === UserTypeEnum.ADMIN"
@@ -58,6 +69,14 @@
           </div>
         </div>
       </el-scrollbar>
+      <div
+        v-show="showNewMessageTip"
+        class="newMessageTip flex items-center cursor-pointer"
+        @click="handleToNewMessage"
+      >
+        <span>有新消息</span>
+        <Icon class="ml-5px" icon="ep:bottom" />
+      </div>
     </el-main>
     <el-footer height="230px">
       <div class="h-[100%]">
@@ -86,6 +105,8 @@ import EmojiSelectPopover from './tools/EmojiSelectPopover.vue'
 import PictureSelectUpload from './tools/PictureSelectUpload.vue'
 import TextMessageItem from './message/TextMessageItem.vue'
 import ImageMessageItem from './message/ImageMessageItem.vue'
+import ProductMessageItem from './message/ProductMessageItem.vue'
+import OrderMessageItem from './message/OrderMessageItem.vue'
 import { Emoji } from './tools/emoji'
 import { KeFuMessageContentTypeEnum } from './tools/constants'
 import { isEmpty } from '@/utils/is'
@@ -101,23 +122,47 @@ const messageTool = useMessage()
 const message = ref('') // 消息
 const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
 const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
-// 获得消息 TODO puhui999:  先不考虑下拉加载历史消息
+const showNewMessageTip = ref(false) // 显示有新消息提示
+const queryParams = reactive({
+  pageNo: 1,
+  conversationId: 0
+})
+const total = ref(0) // 消息总条数
+// 获得消息
 const getMessageList = async (conversation: KeFuConversationRespVO) => {
   keFuConversation.value = conversation
-  const { list } = await KeFuMessageApi.getKeFuMessagePage({
-    pageNo: 1,
-    conversationId: conversation.id
-  })
-  messageList.value = list.reverse()
-  // TODO puhui999: 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
+  queryParams.conversationId = conversation.id
+  const messageTotal = messageList.value.length
+  if (total.value > 0 && messageTotal > 0 && messageTotal === total.value) {
+    return
+  }
+  const res = await KeFuMessageApi.getKeFuMessagePage(queryParams)
+  total.value = res.total
+  for (const item of res.list) {
+    if (messageList.value.some((val) => val.id === item.id)) {
+      continue
+    }
+    messageList.value.push(item)
+  }
   await scrollToBottom()
 }
+const getMessageList0 = computed(() => {
+  messageList.value.sort((a: any, b: any) => a.createTime - b.createTime)
+  return messageList.value
+})
+
 // 刷新消息列表
-const refreshMessageList = () => {
+const refreshMessageList = async () => {
   if (!keFuConversation.value) {
     return
   }
-  getMessageList(keFuConversation.value)
+
+  queryParams.pageNo = 1
+  await getMessageList(keFuConversation.value)
+  if (loadHistory.value) {
+    // 有下角显示有新消息提示
+    showNewMessageTip.value = true
+  }
 }
 defineExpose({ getMessageList, refreshMessageList })
 // 是否显示聊天区域
@@ -140,7 +185,7 @@ const handleSendPicture = async (picUrl: string) => {
 const handleSendMessage = async () => {
   // 1. 校验消息是否为空
   if (isEmpty(unref(message.value))) {
-    messageTool.warning('请输入消息后再发送哦!')
+    messageTool.notifyWarning('请输入消息后再发送哦!')
     return
   }
   // 2. 组织发送消息
@@ -167,12 +212,41 @@ const innerRef = ref<HTMLDivElement>()
 const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
 // 滚动到底部
 const scrollToBottom = async () => {
-  // 1. 滚动到最新消息
+  // 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
+  if (loadHistory.value) {
+    return
+  }
+  // 2.1 滚动到最新消息,关闭新消息提示
   await nextTick()
   scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
-  // 2. 消息已读
+  showNewMessageTip.value = false
+  // 2.2 消息已读
   await KeFuMessageApi.updateKeFuMessageReadStatus(keFuConversation.value.id)
 }
+// 查看新消息
+const handleToNewMessage = async () => {
+  loadHistory.value = false
+  await scrollToBottom()
+}
+
+const loadingMore = ref(false) // 滚动到顶部加载更多
+const loadHistory = ref(false) // 加载历史消息
+const handleScroll = async ({ scrollTop }) => {
+  const messageTotal = messageList.value.length
+  if (total.value > 0 && messageTotal > 0 && messageTotal === total.value) {
+    return
+  }
+  // 距顶 20 加载下一页数据
+  loadingMore.value = scrollTop < 20
+}
+const handleOldMessage = async () => {
+  loadHistory.value = true
+  // 加载消息列表
+  queryParams.pageNo += 1
+  await getMessageList(keFuConversation.value)
+  loadingMore.value = false
+  // TODO puhui999: 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
+}
 /**
  * 是否显示时间
  * @param {*} item - 数据
@@ -196,6 +270,32 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
   }
 
   &-content {
+    position: relative;
+
+    .loadingMore {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 50px;
+      background-color: #eee;
+      color: #666;
+      text-align: center;
+      line-height: 50px;
+      transform: translateY(-100%);
+      transition: transform 0.3s ease-in-out;
+    }
+
+    .newMessageTip {
+      position: absolute;
+      bottom: 35px;
+      right: 35px;
+      background-color: #fff;
+      padding: 10px;
+      border-radius: 30px;
+      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 阴影效果 */
+    }
+
     .ss-row-left {
       justify-content: flex-start;
 

+ 28 - 31
src/views/mall/promotion/kefu/components/KeFuConversationBox.vue

@@ -1,5 +1,6 @@
 <template>
   <div class="kefu">
+    <!-- TODO @puhui999:item => conversation 会不会更容易理解 -->
     <div
       v-for="(item, index) in conversationList"
       :key="item.id"
@@ -9,7 +10,9 @@
       @contextmenu.prevent="rightClick($event as PointerEvent, item)"
     >
       <div class="flex justify-center items-center w-100%">
+        <!-- TODO style 换成 unocss -->
         <div class="flex justify-center items-center" style="width: 50px; height: 50px">
+          <!-- 头像 + 未读 -->
           <el-badge
             :hidden="item.adminUnreadMessageCount === 0"
             :max="99"
@@ -41,7 +44,8 @@
         </div>
       </div>
     </div>
-    <!-- 通过右击获取到的坐标定位 -->
+
+    <!-- 右键,进行操作(类似微信) -->
     <ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
       <li
         v-show="!selectedConversation.adminPinned"
@@ -74,45 +78,34 @@
 <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 { formatDate } from '@/utils/formatTime'
 import { KeFuMessageContentTypeEnum } from './tools/constants'
 
 defineOptions({ name: 'KeFuConversationBox' })
-const message = useMessage()
+
+const message = useMessage() // 消息弹窗
+
 const { replaceEmoji } = useEmoji()
-const activeConversationIndex = ref(-1) // 选中的会话
 const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表
+const activeConversationIndex = ref(-1) // 选中的会话 index 位置 TODO @puhui999:这个可以改成 activeConversationId 么?因为一般是选中的对话编号
+
+/** 加载会话列表 */
 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)
 }
-// 获得消息类型
+
+// TODO @puhui999:这个,是不是改成 getConversationDisplayText,获取会话的展示文本。然后,把文本消息类型,也统一处理(包括上面的 replaceEmoji)。这样,更统一。
+/** 获得消息类型 */
 const getContentType = computed(() => (lastMessageContentType: number) => {
   switch (lastMessageContentType) {
     case KeFuMessageContentTypeEnum.SYSTEM:
@@ -135,8 +128,9 @@ const getContentType = computed(() => (lastMessageContentType: number) => {
 //======================= 右键菜单 =======================
 const showRightMenu = ref(false) // 显示右键菜单
 const rightMenuStyle = ref<any>({}) // 右键菜单 Style
-const selectedConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 右键选中的会话对象
-// 右键菜单
+const selectedConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 右键选中的会话对象 TODO puhui999:这个是不是叫 rightClickConversation 会好点。因为 selected 容易和选中的对话,定义上有点重叠
+
+/** 打开右键菜单 */
 const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
   selectedConversation.value = item
   // 显示右键菜单
@@ -146,24 +140,25 @@ const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
     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 ? '置顶成功' : '取消置顶成功')
+  message.notifySuccess(adminPinned ? '置顶成功' : '取消置顶成功')
   // 2. 关闭右键菜单,更新会话列表
   closeRightMenu()
   await getConversationList()
 }
-// 删除会话
+
+/** 删除会话 */
 const deleteConversation = async () => {
   // 1. 删除会话
   await message.confirm('您确定要删除该会话吗?')
@@ -172,6 +167,8 @@ const deleteConversation = async () => {
   closeRightMenu()
   await getConversationList()
 }
+
+/** 监听右键菜单的显示状态,添加点击事件监听器 */
 watch(showRightMenu, (val) => {
   if (val) {
     document.body.addEventListener('click', closeRightMenu)

+ 182 - 0
src/views/mall/promotion/kefu/components/message/OrderMessageItem.vue

@@ -0,0 +1,182 @@
+<template>
+  <!-- 图片消息 -->
+  <template v-if="KeFuMessageContentTypeEnum.ORDER === message.contentType">
+    <div
+      :class="[
+        message.senderType === UserTypeEnum.MEMBER
+          ? `ml-10px`
+          : message.senderType === UserTypeEnum.ADMIN
+            ? `mr-10px`
+            : ''
+      ]"
+    >
+      <div :key="getMessageContent.id" class="order-list-card-box mt-14px">
+        <div class="order-card-header flex items-center justify-between p-x-20px">
+          <div class="order-no">订单号:{{ getMessageContent.no }}</div>
+          <div :class="formatOrderColor(getMessageContent)" class="order-state font-26">
+            {{ formatOrderStatus(getMessageContent) }}
+          </div>
+        </div>
+        <div v-for="item in getMessageContent.items" :key="item.id" class="border-bottom">
+          <ProductItem
+            :img="item.picUrl"
+            :num="item.count"
+            :price="item.price"
+            :skuText="item.properties.map((property: any) => property.valueName).join(' ')"
+            :title="item.spuName"
+          />
+        </div>
+        <div class="pay-box mt-30px flex justify-end pr-20px">
+          <div class="flex items-center">
+            <div class="discounts-title pay-color"
+              >共 {{ getMessageContent.productCount }} 件商品,总金额:
+            </div>
+            <div class="discounts-money pay-color">
+              ¥{{ fenToYuan(getMessageContent.payPrice) }}
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { KeFuMessageContentTypeEnum } from '../tools/constants'
+import ProductItem from './ProductItem.vue'
+import { UserTypeEnum } from '@/utils/constants'
+import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { fenToYuan } from '@/utils'
+
+defineOptions({ name: 'OrderMessageItem' })
+const props = defineProps<{
+  message: KeFuMessageRespVO
+}>()
+const getMessageContent = computed(() => JSON.parse(props.message.content))
+
+/**
+ * 格式化订单状态的颜色
+ *
+ * @param order 订单
+ * @return {string} 颜色的 class 名称
+ */
+function formatOrderColor(order) {
+  if (order.status === 0) {
+    return 'info-color'
+  }
+  if (order.status === 10 || order.status === 20 || (order.status === 30 && !order.commentStatus)) {
+    return 'warning-color'
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return 'success-color'
+  }
+  return 'danger-color'
+}
+
+/**
+ * 格式化订单状态
+ *
+ * @param order 订单
+ */
+function formatOrderStatus(order) {
+  if (order.status === 0) {
+    return '待付款'
+  }
+  if (order.status === 10 && order.deliveryType === 1) {
+    return '待发货'
+  }
+  if (order.status === 10 && order.deliveryType === 2) {
+    return '待核销'
+  }
+  if (order.status === 20) {
+    return '待收货'
+  }
+  if (order.status === 30 && !order.commentStatus) {
+    return '待评价'
+  }
+  if (order.status === 30 && order.commentStatus) {
+    return '已完成'
+  }
+  return '已关闭'
+}
+</script>
+
+<style lang="scss" scoped>
+.order-list-card-box {
+  border-radius: 10px;
+  padding: 10px;
+  background-color: #e2e2e2;
+
+  .order-card-header {
+    height: 80rpx;
+
+    .order-no {
+      font-size: 26rpx;
+      font-weight: 500;
+    }
+  }
+
+  .pay-box {
+    .discounts-title {
+      font-size: 24rpx;
+      line-height: normal;
+      color: #999999;
+    }
+
+    .discounts-money {
+      font-size: 24rpx;
+      line-height: normal;
+      color: #999;
+      font-family: OPPOSANS;
+    }
+
+    .pay-color {
+      color: #333;
+    }
+  }
+
+  .order-card-footer {
+    height: 100rpx;
+
+    .more-item-box {
+      padding: 20rpx;
+
+      .more-item {
+        height: 60rpx;
+
+        .title {
+          font-size: 26rpx;
+        }
+      }
+    }
+
+    .more-btn {
+      color: #999999;
+      font-size: 24rpx;
+    }
+
+    .content {
+      width: 154rpx;
+      color: #333333;
+      font-size: 26rpx;
+      font-weight: 500;
+    }
+  }
+}
+
+.warning-color {
+  color: #faad14;
+}
+
+.danger-color {
+  color: #ff3000;
+}
+
+.success-color {
+  color: #52c41a;
+}
+
+.info-color {
+  color: #999999;
+}
+</style>

+ 195 - 0
src/views/mall/promotion/kefu/components/message/ProductItem.vue

@@ -0,0 +1,195 @@
+<template>
+  <div>
+    <div>
+      <slot name="top"></slot>
+    </div>
+    <div
+      :style="[{ borderRadius: radius + 'px', marginBottom: marginBottom + 'px' }]"
+      class="ss-order-card-warp flex items-stretch justify-between bg-white"
+    >
+      <div class="img-box mr-24px">
+        <el-image :src="img" class="order-img" fit="contain" @click="imagePrediv(img)" />
+      </div>
+      <div
+        :style="[{ width: titleWidth ? titleWidth + 'px' : '' }]"
+        class="box-right flex flex-col justify-between"
+      >
+        <div v-if="title" class="title-text ss-line-2">{{ title }}</div>
+        <div v-if="skuString" class="spec-text mt-8px mb-12px">{{ skuString }}</div>
+        <div class="groupon-box">
+          <slot name="groupon"></slot>
+        </div>
+        <div class="flex">
+          <div class="flex items-center">
+            <div
+              v-if="price && Number(price) > 0"
+              :style="[{ color: priceColor }]"
+              class="price-text flex items-center"
+            >
+              ¥{{ fenToYuan(price) }}
+            </div>
+            <div v-if="num" class="total-text flex items-center">x {{ num }}</div>
+            <slot name="priceSuffix"></slot>
+          </div>
+        </div>
+        <div class="tool-box">
+          <slot name="tool"></slot>
+        </div>
+        <div>
+          <slot name="rightBottom"></slot>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { createImageViewer } from '@/components/ImageViewer'
+import { fenToYuan } from '@/utils'
+
+defineOptions({ name: 'ProductItem' })
+const props = defineProps({
+  img: {
+    type: String,
+    default: 'https://img1.baidu.com/it/u=1601695551,235775011&fm=26&fmt=auto'
+  },
+  title: {
+    type: String,
+    default: ''
+  },
+  titleWidth: {
+    type: Number,
+    default: 0
+  },
+  skuText: {
+    type: [String, Array],
+    default: ''
+  },
+  price: {
+    type: [String, Number],
+    default: ''
+  },
+  priceColor: {
+    type: [String],
+    default: ''
+  },
+  num: {
+    type: [String, Number],
+    default: 0
+  },
+  score: {
+    type: [String, Number],
+    default: ''
+  },
+  radius: {
+    type: [String],
+    default: ''
+  },
+  marginBottom: {
+    type: [String],
+    default: ''
+  }
+})
+const skuString = computed(() => {
+  if (!props.skuText) {
+    return ''
+  }
+  if (typeof props.skuText === 'object') {
+    return props.skuText.join(',')
+  }
+  return props.skuText
+})
+/** 图预览 */
+const imagePrediv = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.score-img {
+  width: 36px;
+  height: 36px;
+  margin: 0 4px;
+}
+
+.ss-order-card-warp {
+  padding: 20px;
+  border-radius: 10px;
+  background-color: #e2e2e2;
+
+  .img-box {
+    width: 164px;
+    height: 164px;
+    border-radius: 10px;
+    overflow: hidden;
+
+    .order-img {
+      width: 164px;
+      height: 164px;
+    }
+  }
+
+  .box-right {
+    flex: 1;
+    // width: 500px;
+    // height: 164px;
+    position: relative;
+
+    .tool-box {
+      position: absolute;
+      right: 0px;
+      bottom: -10px;
+    }
+  }
+
+  .title-text {
+    font-size: 28px;
+    font-weight: 500;
+    line-height: 40px;
+  }
+
+  .spec-text {
+    font-size: 24px;
+    font-weight: 400;
+    color: #999999;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    display: -webkit-box;
+    -webkit-line-clamp: 1;
+    -webkit-box-orient: vertical;
+  }
+
+  .price-text {
+    font-size: 24px;
+    font-weight: 500;
+    font-family: OPPOSANS;
+  }
+
+  .total-text {
+    font-size: 24px;
+    font-weight: 400;
+    line-height: 24px;
+    color: #999999;
+    margin-left: 8px;
+  }
+}
+
+.ss-line {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+
+  &-1 {
+    -webkit-line-clamp: 1;
+  }
+
+  &-2 {
+    -webkit-line-clamp: 2;
+  }
+}
+</style>

+ 36 - 0
src/views/mall/promotion/kefu/components/message/ProductMessageItem.vue

@@ -0,0 +1,36 @@
+<template>
+  <!-- 图片消息 -->
+  <template v-if="KeFuMessageContentTypeEnum.PRODUCT === message.contentType">
+    <div
+      :class="[
+        message.senderType === UserTypeEnum.MEMBER
+          ? `ml-10px`
+          : message.senderType === UserTypeEnum.ADMIN
+            ? `mr-10px`
+            : ''
+      ]"
+    >
+      <ProductItem
+        :img="getMessageContent.picUrl"
+        :price="getMessageContent.price"
+        :skuText="getMessageContent.introduction"
+        :title="getMessageContent.spuName"
+        :titleWidth="400"
+        priceColor="#FF3000"
+      />
+    </div>
+  </template>
+</template>
+
+<script lang="ts" setup>
+import { KeFuMessageContentTypeEnum } from '../tools/constants'
+import ProductItem from './ProductItem.vue'
+import { UserTypeEnum } from '@/utils/constants'
+import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+
+defineOptions({ name: 'ProductMessageItem' })
+const props = defineProps<{
+  message: KeFuMessageRespVO
+}>()
+const getMessageContent = computed(() => JSON.parse(props.message.content))
+</script>

+ 1 - 0
src/views/mall/promotion/kefu/components/tools/constants.ts

@@ -9,6 +9,7 @@ export const KeFuMessageContentTypeEnum = {
   PRODUCT: 10, //  商品消息
   ORDER: 11 //  订单消息"
 }
+
 // Promotion 的 WebSocket 消息类型枚举类
 export const WebSocketMessageTypeConstants = {
   KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型

+ 25 - 14
src/views/mall/promotion/kefu/index.vue

@@ -1,10 +1,13 @@
 <template>
   <el-row :gutter="10">
+    <!-- TODO @puhui999:KeFuConversationBox => KeFuConversationList ;KeFuChatBox => KeFuMessageList -->
+    <!-- 会话列表 -->
     <el-col :span="8">
       <ContentWrap>
         <KeFuConversationBox ref="keFuConversationRef" @change="handleChange" />
       </ContentWrap>
     </el-col>
+    <!-- 会话详情(选中会话的消息列表) -->
     <el-col :span="16">
       <ContentWrap>
         <KeFuChatBox ref="keFuChatBoxRef" @change="getConversationList" />
@@ -21,15 +24,10 @@ import { getAccessToken } from '@/utils/auth'
 import { useWebSocket } from '@vueuse/core'
 
 defineOptions({ name: 'KeFu' })
-const message = useMessage()
 
-// 加载消息
-const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
-const handleChange = (conversation: KeFuConversationRespVO) => {
-  keFuChatBoxRef.value?.getMessageList(conversation)
-}
+const message = useMessage() // 消息弹窗
 
-//======================= websocket start=======================
+// ======================= WebSocket start =======================
 const server = ref(
   (import.meta.env.VITE_BASE_URL + '/infra/ws/').replace('http', 'ws') +
     '?token=' +
@@ -38,9 +36,11 @@ const server = ref(
 
 /** 发起 WebSocket 连接 */
 const { data, close, open } = useWebSocket(server.value, {
-  autoReconnect: false,
+  autoReconnect: false, // TODO @puhui999:重连要加下
   heartbeat: true
 })
+
+/** 监听 WebSocket 数据 */
 watchEffect(() => {
   if (!data.value) {
     return
@@ -75,17 +75,28 @@ watchEffect(() => {
     console.error(error)
   }
 })
-//======================= websocket end=======================
-// 加载会话列表
+// ======================= WebSocket end =======================
+
+/** 加载会话列表 */
 const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>()
 const getConversationList = () => {
   keFuConversationRef.value?.getConversationList()
 }
+
+/** 加载指定会话的消息列表 */
+const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
+const handleChange = (conversation: KeFuConversationRespVO) => {
+  keFuChatBoxRef.value?.getMessageList(conversation)
+}
+
+/** 初始化 */
 onMounted(() => {
   getConversationList()
   // 打开 websocket 连接
   open()
 })
+
+/** 销毁 */
 onBeforeUnmount(() => {
   // 关闭 websocket 连接
   close()
@@ -104,17 +115,17 @@ onBeforeUnmount(() => {
   height: 6px;
 }
 
-/*定义滚动条轨道 内阴影+圆角*/
+/* 定义滚动条轨道 内阴影+圆角 */
 ::-webkit-scrollbar-track {
-  box-shadow: inset 0 0 0px rgba(240, 240, 240, 0.5);
+  box-shadow: inset 0 0 0 rgba(240, 240, 240, 0.5);
   border-radius: 10px;
   background-color: #fff;
 }
 
-/*定义滑块 内阴影+圆角*/
+/* 定义滑块 内阴影+圆角 */
 ::-webkit-scrollbar-thumb {
   border-radius: 10px;
-  box-shadow: inset 0 0 0px rgba(240, 240, 240, 0.5);
+  box-shadow: inset 0 0 0 rgba(240, 240, 240, 0.5);
   background-color: rgba(240, 240, 240, 0.5);
 }
 </style>