Commit a68ae29f by hhhero

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

# Conflicts:
#	src/views/ai/utils/constants.ts
parents 9e628ba5 65509834
...@@ -12,16 +12,11 @@ export interface ImageVO { ...@@ -12,16 +12,11 @@ export interface ImageVO {
publicStatus: boolean // 公开状态 publicStatus: boolean // 公开状态
picUrl: string // 任务地址 picUrl: string // 任务地址
errorMessage: string // 错误信息 errorMessage: string // 错误信息
options: object // 配置 Map<string, string> options: any // 配置 Map<string, string>
taskId: number // 任务编号 taskId: number // 任务编号
buttons: ImageMjButtonsVO[] // mj 操作按钮 buttons: ImageMidjourneyButtonsVO[] // mj 操作按钮
createTime: string // 创建时间 createTime: Date // 创建时间
finishTime: string // 完成时间 finishTime: Date // 完成时间
}
export interface ImagePageReqVO {
pageNo: number // 分页编号
pageSize: number // 分页大小
} }
export interface ImageDrawReqVO { export interface ImageDrawReqVO {
...@@ -43,22 +38,22 @@ export interface ImageMidjourneyImagineReqVO { ...@@ -43,22 +38,22 @@ export interface ImageMidjourneyImagineReqVO {
version: string // 版本 version: string // 版本
} }
export interface ImageMjActionVO { export interface ImageMidjourneyActionVO {
id: number // 图片编号 id: number // 图片编号
customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识 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 动作标识 customId: string // MJ::JOB::upsample::1::85a4b4c1-8835-46c5-a15c-aea34fad1862 动作标识
emoji: string // 图标 emoji emoji: string // 图标 emoji
label: string // Make Variations 文本 label: string // Make Variations 文本
style: number // 样式: 2(Primary)、3(Green) style: number // 样式: 2(Primary)、3(Green)
} }
// AI API 密钥 API // AI 图片 API
export const ImageApi = { export const ImageApi = {
// 获取【我的】绘图分页 // 获取【我的】绘图分页
getImagePageMy: async (params: ImagePageReqVO) => { getImagePageMy: async (params: PageParam) => {
return await request.get({ url: `/ai/image/my-page`, params }) return await request.get({ url: `/ai/image/my-page`, params })
}, },
// 获取【我的】绘图记录 // 获取【我的】绘图记录
...@@ -85,7 +80,7 @@ export const ImageApi = { ...@@ -85,7 +80,7 @@ export const ImageApi = {
return await request.post({ url: `/ai/image/midjourney/imagine`, data }) return await request.post({ url: `/ai/image/midjourney/imagine`, data })
}, },
// 【Midjourney】Action 操作(二次生成图片) // 【Midjourney】Action 操作(二次生成图片)
midjourneyAction: async (data: ImageMjActionVO) => { midjourneyAction: async (data: ImageMidjourneyActionVO) => {
return await request.post({ url: `/ai/image/midjourney/action`, data }) return await request.post({ url: `/ai/image/midjourney/action`, data })
}, },
......
<?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>
\ No newline at end of file
...@@ -32,26 +32,25 @@ const download = { ...@@ -32,26 +32,25 @@ const download = {
// 下载 Markdown 方法 // 下载 Markdown 方法
markdown: (data: Blob, fileName: string) => { markdown: (data: Blob, fileName: string) => {
download0(data, fileName, 'text/markdown') 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 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()
}
}
<template> <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"> <div class="chat-list" v-for="(item, index) in list" :key="index">
<!-- 靠左 message:system、assistant 类型 --> <!-- 靠左 message:system、assistant 类型 -->
<div class="left-message message-item" v-if="item.type !== 'user'"> <div class="left-message message-item" v-if="item.type !== 'user'">
...@@ -101,13 +101,12 @@ const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 ...@@ -101,13 +101,12 @@ const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义
/** 滚动到底部 */ /** 滚动到底部 */
const scrollToBottom = async (isIgnore?: boolean) => { const scrollToBottom = async (isIgnore?: boolean) => {
// 注意要使用 nextTick 以免获取不到dom // 注意要使用 nextTick 以免获取不到 dom
await nextTick(() => { await nextTick()
if (isIgnore || !isScrolling.value) { if (isIgnore || !isScrolling.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollTop =
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
} }
})
} }
function handleScroll() { function handleScroll() {
......
...@@ -10,15 +10,13 @@ ...@@ -10,15 +10,13 @@
<el-icon><More /></el-icon> <el-icon><More /></el-icon>
</el-button> </el-button>
</span> </span>
<!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item :command="['edit', role]"> <el-dropdown-item :command="['edit', role]">
<el-icon><EditPen /></el-icon>编辑 <Icon icon="ep:edit" color="#787878" />编辑
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item :command="['delete', role]" style="color: red"> <el-dropdown-item :command="['delete', role]" style="color: red">
<el-icon><Delete /></el-icon> <Icon icon="ep:delete" color="red" />删除
<span>删除</span>
</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
...@@ -43,9 +41,9 @@ ...@@ -43,9 +41,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ChatRoleVO } from '@/api/ai/model/chatRole' import {ChatRoleVO} from '@/api/ai/model/chatRole'
import { PropType, ref } from 'vue' import {PropType, ref} from 'vue'
import { Delete, EditPen, More } from '@element-plus/icons-vue' import {More} from '@element-plus/icons-vue'
const tabsRef = ref<any>() // tabs ref const tabsRef = ref<any>() // tabs ref
......
...@@ -23,10 +23,7 @@ ...@@ -23,10 +23,7 @@
@click="handlerAddRole" @click="handlerAddRole"
class="ml-20px" class="ml-20px"
> >
<!-- TODO @fan:下面两个 icon,可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 --> <Icon icon="ep:user" style="margin-right: 5px;" />
<el-icon>
<User />
</el-icon>
添加角色 添加角色
</el-button> </el-button>
</div> </div>
...@@ -67,15 +64,15 @@ ...@@ -67,15 +64,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import {ref} from 'vue'
import RoleHeader from './RoleHeader.vue' import RoleHeader from './RoleHeader.vue'
import RoleList from './RoleList.vue' import RoleList from './RoleList.vue'
import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue' import ChatRoleForm from '@/views/ai/model/chatRole/ChatRoleForm.vue'
import RoleCategoryList from './RoleCategoryList.vue' import RoleCategoryList from './RoleCategoryList.vue'
import { ChatRoleApi, ChatRolePageReqVO, ChatRoleVO } from '@/api/ai/model/chatRole' import {ChatRoleApi, ChatRolePageReqVO, ChatRoleVO} from '@/api/ai/model/chatRole'
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation' import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
import { Search, User } from '@element-plus/icons-vue' import {Search} from '@element-plus/icons-vue'
import { TabsPaneContext } from 'element-plus' import {TabsPaneContext} from 'element-plus'
const router = useRouter() // 路由对象 const router = useRouter() // 路由对象
...@@ -222,15 +219,14 @@ onMounted(async () => { ...@@ -222,15 +219,14 @@ onMounted(async () => {
// 获取 role 数据 // 获取 role 数据
await getActiveTabsRole() await getActiveTabsRole()
}) })
// TODO @fan:css 是不是可以融合到 scss 里面呀?
</script> </script>
<style lang="css"> <!-- 覆盖 element ui css -->
<style lang="scss">
.el-tabs__content { .el-tabs__content {
position: relative; position: relative;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
.el-tabs__nav-scroll { .el-tabs__nav-scroll {
margin: 10px 20px; margin: 10px 20px;
} }
......
...@@ -22,11 +22,14 @@ ...@@ -22,11 +22,14 @@
<Icon icon="ep:setting" class="ml-10px" /> <Icon icon="ep:setting" class="ml-10px" />
</el-button> </el-button>
<el-button size="small" class="btn" @click="handlerMessageClear"> <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> </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> </div>
</el-header> </el-header>
...@@ -180,11 +183,6 @@ const handleConversationClick = async (conversation: ChatConversationVO) => { ...@@ -180,11 +183,6 @@ const handleConversationClick = async (conversation: ChatConversationVO) => {
// 更新选中的对话 id // 更新选中的对话 id
activeConversationId.value = conversation.id activeConversationId.value = conversation.id
activeConversation.value = conversation activeConversation.value = conversation
// 处理进行中的对话
// TODO @fan:这里,和上面的 “对话进行中,不允许切换” 是不是重叠了?
if (conversationInProgress.value) {
await stopStream()
}
// 刷新 message 列表 // 刷新 message 列表
await getMessageList() await getMessageList()
// 滚动底部 // 滚动底部
...@@ -203,7 +201,11 @@ const handlerConversationDelete = async (delConversation: ChatConversationVO) => ...@@ -203,7 +201,11 @@ const handlerConversationDelete = async (delConversation: ChatConversationVO) =>
} }
/** 清空选中的对话 */ /** 清空选中的对话 */
const handleConversationClear = async () => { const handleConversationClear = async () => {
// TODO @fan:需要加一个 对话进行中,不允许切换 // 对话进行中,不允许切换
if (conversationInProgress.value) {
message.alert('对话中,不允许切换!')
return false
}
activeConversationId.value = null activeConversationId.value = null
activeConversation.value = null activeConversation.value = null
activeMessageList.value = [] activeMessageList.value = []
...@@ -363,7 +365,7 @@ const handlePromptInput = (event) => { ...@@ -363,7 +365,7 @@ const handlePromptInput = (event) => {
isComposing.value = false isComposing.value = false
}, 400) }, 400)
} }
// TODO @fan:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑 // TODO @芋艿:是不是可以通过 @keydown.enter、@keydown.shift.enter 来实现,回车发送、shift+回车换行;主要看看,是不是可以简化 isComposing 相关的逻辑
const onCompositionstart = () => { const onCompositionstart = () => {
isComposing.value = true isComposing.value = true
} }
...@@ -394,7 +396,6 @@ const doSendMessage = async (content: string) => { ...@@ -394,7 +396,6 @@ const doSendMessage = async (content: string) => {
} as ChatMessageVO) } as ChatMessageVO)
} }
// TODO @fan:= = 不知道哪里被改动了。点击【发送】后,不会跳转到消息最底部了。。
/** 真正执行【发送】消息操作 */ /** 真正执行【发送】消息操作 */
const doSendMessageStream = async (userMessage: ChatMessageVO) => { const doSendMessageStream = async (userMessage: ChatMessageVO) => {
// 创建 AbortController 实例,以便中止请求 // 创建 AbortController 实例,以便中止请求
...@@ -421,9 +422,8 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => { ...@@ -421,9 +422,8 @@ const doSendMessageStream = async (userMessage: ChatMessageVO) => {
createTime: new Date() createTime: new Date()
} as ChatMessageVO) } as ChatMessageVO)
// 1.2 滚动到最下面 // 1.2 滚动到最下面
nextTick(async () => { await nextTick()
await scrollToBottom() // 底部 await scrollToBottom() // 底部
})
// 1.3 开始滚动 // 1.3 开始滚动
textRoll() textRoll()
...@@ -573,7 +573,6 @@ onMounted(async () => { ...@@ -573,7 +573,6 @@ onMounted(async () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.ai-layout { .ai-layout {
// TODO @范 这里height不能 100% 先这样临时处理 TODO @fan:这个目前要搞处理么?
position: absolute; position: absolute;
flex: 1; flex: 1;
top: 0; top: 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>
...@@ -2,58 +2,53 @@ ...@@ -2,58 +2,53 @@
<el-card body-class="" class="image-card"> <el-card body-class="" class="image-card">
<div class="image-operation"> <div class="image-operation">
<div> <div>
<el-button <el-button type="primary" text bg v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS">
type="primary"
text
bg
v-if="imageDetail?.status === AiImageStatusEnum.IN_PROGRESS"
>
生成中 生成中
</el-button> </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>
<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> </el-button>
</div> </div>
<!-- 操作区 -->
<div> <div>
<el-button <el-button
class="btn" class="btn"
text text
:icon="Download" :icon="Download"
@click="handleBtnClick('download', imageDetail)" @click="handleButtonClick('download', detail)"
/> />
<el-button <el-button
class="btn" class="btn"
text text
:icon="RefreshRight" :icon="RefreshRight"
@click="handleBtnClick('regeneration', imageDetail)" @click="handleButtonClick('regeneration', detail)"
/> />
<el-button <el-button class="btn" text :icon="Delete" @click="handleButtonClick('delete', detail)" />
class="btn" <el-button class="btn" text :icon="More" @click="handleButtonClick('more', detail)" />
text
:icon="Delete"
@click="handleBtnClick('delete', imageDetail)"
/>
<el-button class="btn" text :icon="More" @click="handleBtnClick('more', imageDetail)" />
</div> </div>
</div> </div>
<div class="image-wrapper" ref="cardImageRef"> <div class="image-wrapper" ref="cardImageRef">
<!-- TODO @fan:要不加个点击,大图预览? --> <el-image
<img class="image" :src="imageDetail?.picUrl" /> class="image"
<div v-if="imageDetail?.status === AiImageStatusEnum.FAIL"> :src="detail?.picUrl"
{{ imageDetail?.errorMessage }} :preview-src-list="[detail.picUrl]"
preview-teleported
/>
<div v-if="detail?.status === AiImageStatusEnum.FAIL">
{{ detail?.errorMessage }}
</div> </div>
</div> </div>
<!-- TODO @fan:style 使用 unocss 替代下 --> <!-- Midjourney 专属操作 -->
<div class="image-mj-btns"> <div class="image-mj-btns">
<el-button <el-button
size="small" size="small"
v-for="button in imageDetail?.buttons" v-for="button in detail?.buttons"
:key="button" :key="button"
style="min-width: 40px; margin-left: 0; margin-right: 10px; margin-top: 5px" class="min-w-40px ml-0 mr-10px mt-5px"
@click="handleMjBtnClick(button)" @click="handleMidjourneyBtnClick(button)"
> >
{{ button.label }}{{ button.emoji }} {{ button.label }}{{ button.emoji }}
</el-button> </el-button>
...@@ -61,34 +56,53 @@ ...@@ -61,34 +56,53 @@
</el-card> </el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {Delete, Download, More, RefreshRight} from '@element-plus/icons-vue' import { Delete, Download, More, RefreshRight } from '@element-plus/icons-vue'
import { ImageVO, ImageMjButtonsVO } from '@/api/ai/image' import { ImageVO, ImageMidjourneyButtonsVO } from '@/api/ai/image'
import { PropType } from 'vue' import { PropType } from 'vue'
import {ElLoading, LoadingOptionsResolved} from 'element-plus' import { ElLoading, LoadingOptionsResolved } from 'element-plus'
import { AiImageStatusEnum } from '@/views/ai/utils/constants' import { AiImageStatusEnum } from '@/views/ai/utils/constants'
const cardImageRef = ref<any>() // 卡片 image ref const message = useMessage() // 消息
const cardImageLoadingInstance = ref<any>() // 卡片 image ref
const message = useMessage()
const props = defineProps({ const props = defineProps({
imageDetail: { detail: {
type: Object as PropType<ImageVO>, type: Object as PropType<ImageVO>,
require: true require: true
} }
}) })
/** 按钮 - 点击事件 */ const cardImageRef = ref<any>() // 卡片 image ref
const handleBtnClick = async (type, imageDetail: ImageVO) => { const cardImageLoadingInstance = ref<any>() // 卡片 image ref
emits('onBtnClick', type, imageDetail)
/** 处理点击事件 */
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) => { const handleLoading = async (status: number) => {
// TODO @芋艿:这个搞成 Loading 组件,然后通过数据驱动,这样搞可以哇? // 情况一:如果是生成中,则设置加载中的 loading
if (status === AiImageStatusEnum.IN_PROGRESS) { if (status === AiImageStatusEnum.IN_PROGRESS) {
cardImageLoadingInstance.value = ElLoading.service({ cardImageLoadingInstance.value = ElLoading.service({
target: cardImageRef.value, target: cardImageRef.value,
text: '生成中...' text: '生成中...'
} as LoadingOptionsResolved) } as LoadingOptionsResolved)
// 情况二:如果已经生成结束,则移除 loading
} else { } else {
if (cardImageLoadingInstance.value) { if (cardImageLoadingInstance.value) {
cardImageLoadingInstance.value.close() cardImageLoadingInstance.value.close()
...@@ -97,25 +111,9 @@ const handleLoading = async (status: number) => { ...@@ -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 () => { onMounted(async () => {
await handleLoading(props.imageDetail.status as string) await handleLoading(props.detail.status as string)
}) })
</script> </script>
......
<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>
<template> <template>
<el-card class="dr-task" body-class="task-card" shadow="never"> <el-card class="dr-task" body-class="task-card" shadow="never">
<template #header>绘画任务</template> <template #header>绘画任务</template>
<div class="task-image-list" ref="imageTaskRef"> <!-- 图片列表 -->
<ImageTaskCard <div class="task-image-list" ref="imageListRef">
<ImageCard
v-for="image in imageList" v-for="image in imageList"
:key="image" :key="image.id"
:image-detail="image" :detail="image"
@on-btn-click="handleImageBtnClick" @on-btn-click="handleImageButtonClick"
@on-mj-btn-click="handleImageMjBtnClick" @on-mj-btn-click="handleImageMidjourneyButtonClick"
/> />
</div> </div>
<div class="task-image-pagination"> <div class="task-image-pagination">
<el-pagination <Pagination
background
layout="prev, pager, next"
:default-page-size="pageSize"
:total="pageTotal" :total="pageTotal"
@change="handlePageChange" v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getImageList"
/> />
</div> </div>
</el-card> </el-card>
<!-- 图片 detail 抽屉 -->
<ImageDetailDrawer <!-- 图片详情 -->
<ImageDetail
:show="isShowImageDetail" :show="isShowImageDetail"
:id="showImageDetailId" :id="showImageDetailId"
@handle-drawer-close="handleDrawerClose" @handle-drawer-close="handleDetailClose"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ImageApi, ImageVO, ImageMjActionVO, ImageMjButtonsVO } from '@/api/ai/image' import {
import ImageDetailDrawer from './ImageDetailDrawer.vue' ImageApi,
import ImageTaskCard from './ImageTaskCard.vue' ImageVO,
ImageMidjourneyActionVO,
ImageMidjourneyButtonsVO
} from '@/api/ai/image'
import ImageDetail from './ImageDetail.vue'
import ImageCard from './ImageCard.vue'
import { ElLoading, LoadingOptionsResolved } from 'element-plus' import { ElLoading, LoadingOptionsResolved } from 'element-plus'
import { AiImageStatusEnum } from '@/views/ai/utils/constants' import { AiImageStatusEnum } from '@/views/ai/utils/constants'
import { downloadImage } from '@/utils/download' import download from '@/utils/download'
const message = useMessage() // 消息弹窗 const message = useMessage() // 消息弹窗
// 图片分页相关的参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10
})
const pageTotal = ref<number>(0) // page size
const imageList = ref<ImageVO[]>([]) // image 列表 const imageList = ref<ImageVO[]>([]) // image 列表
const imageListLoadingInstance = ref<any>() // image 列表是否正在加载中
const imageListRef = ref<any>() // ref
// 图片轮询相关的参数(正在生成中的)
const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image const inProgressImageMap = ref<{}>({}) // 监听的 image 映射,一般是生成中(需要轮询),key 为 image 编号,value 为 image
const imageListInterval = ref<any>() // image 列表定时器,刷新列表 const inProgressTimer = ref<any>() // 生成中的 image 定时器,轮询生成进展
const isShowImageDetail = ref<boolean>(false) // 是否显示 task 详情 // 图片详情相关的参数
const showImageDetailId = ref<number>(0) // 是否显示 task 详情 const isShowImageDetail = ref<boolean>(false) // 图片详情是否展示
const imageTaskRef = ref<any>() // ref const showImageDetailId = ref<number>(0) // 图片详情的图片编号
const imageTaskLoadingInstance = ref<any>() // loading
const imageTaskLoading = ref<boolean>(false) // loading
const pageNo = ref<number>(1) // page no
const pageSize = ref<number>(10) // page size
const pageTotal = ref<number>(0) // page size
/** 抽屉 - close */ /** 查看图片的详情 */
const handleDrawerClose = async () => { const handleDetailOpen = async () => {
isShowImageDetail.value = false isShowImageDetail.value = true
} }
/** 任务 - detail */ /** 关闭图片的详情 */
const handleDrawerOpen = async () => { const handleDetailClose = async () => {
isShowImageDetail.value = true isShowImageDetail.value = false
} }
/** /** 获得 image 图片列表 */
* 获取 - image 列表 const getImageList = async () => {
*/
const getImageList = async (apply: boolean = false) => {
imageTaskLoading.value = true
try { try {
imageTaskLoadingInstance.value = ElLoading.service({ // 1. 加载图片列表
target: imageTaskRef.value, imageListLoadingInstance.value = ElLoading.service({
target: imageListRef.value,
text: '加载中...' text: '加载中...'
} as LoadingOptionsResolved) } as LoadingOptionsResolved)
const { list, total } = await ImageApi.getImagePageMy({ const { list, total } = await ImageApi.getImagePageMy(queryParams)
pageNo: pageNo.value, imageList.value = list
pageSize: pageSize.value
})
if (apply) {
imageList.value = [...imageList.value, ...list]
} else {
imageList.value = list
}
pageTotal.value = total pageTotal.value = total
// 需要 watch 的数据
// 2. 计算需要轮询的图片
const newWatImages = {} const newWatImages = {}
imageList.value.forEach((item) => { imageList.value.forEach((item) => {
if (item.status === AiImageStatusEnum.IN_PROGRESS) { if (item.status === AiImageStatusEnum.IN_PROGRESS) {
...@@ -88,9 +90,10 @@ const getImageList = async (apply: boolean = false) => { ...@@ -88,9 +90,10 @@ const getImageList = async (apply: boolean = false) => {
}) })
inProgressImageMap.value = newWatImages inProgressImageMap.value = newWatImages
} finally { } finally {
if (imageTaskLoadingInstance.value) { // 关闭正在“加载中”的 Loading
imageTaskLoadingInstance.value.close() if (imageListLoadingInstance.value) {
imageTaskLoadingInstance.value = null imageListLoadingInstance.value.close()
imageListLoadingInstance.value = null
} }
} }
} }
...@@ -117,50 +120,52 @@ const refreshWatchImages = async () => { ...@@ -117,50 +120,52 @@ const refreshWatchImages = async () => {
inProgressImageMap.value = newWatchImages inProgressImageMap.value = newWatchImages
} }
/** 图片 - btn click */ /** 图片的点击事件 */
const handleImageBtnClick = async (type: string, imageDetail: ImageVO) => { const handleImageButtonClick = async (type: string, imageDetail: ImageVO) => {
// 获取 image detail id // 详情
showImageDetailId.value = imageDetail.id
// 处理不用 btn
if (type === 'more') { if (type === 'more') {
await handleDrawerOpen() showImageDetailId.value = imageDetail.id
} else if (type === 'delete') { await handleDetailOpen()
return
}
// 删除
if (type === 'delete') {
await message.confirm(`是否删除照片?`) await message.confirm(`是否删除照片?`)
await ImageApi.deleteImageMy(imageDetail.id) await ImageApi.deleteImageMy(imageDetail.id)
await getImageList() await getImageList()
message.success('删除成功!') message.success('删除成功!')
} else if (type === 'download') { return
await downloadImage(imageDetail.picUrl) }
} else if (type === 'regeneration') { // 下载
// Midjourney 平台 if (type === 'download') {
console.log('regeneration', imageDetail.id) await download.image(imageDetail.picUrl)
return
}
// 重新生成
if (type === 'regeneration') {
await emits('onRegeneration', imageDetail) await emits('onRegeneration', imageDetail)
return
} }
} }
/** 图片 - mj btn click */ /** 处理 Midjourney 按钮点击事件 */
const handleImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageVO) => { const handleImageMidjourneyButtonClick = async (
// 1、构建 params 参数 button: ImageMidjourneyButtonsVO,
imageDetail: ImageVO
) => {
// 1. 构建 params 参数
const data = { const data = {
id: imageDetail.id, id: imageDetail.id,
customId: button.customId customId: button.customId
} as ImageMjActionVO } as ImageMidjourneyActionVO
// 2发送 action // 2. 发送 action
await ImageApi.midjourneyAction(data) await ImageApi.midjourneyAction(data)
// 3刷新列表 // 3. 刷新列表
await getImageList() await getImageList()
} }
// page change defineExpose({ getImageList }) // 暴露组件方法
const handlePageChange = async (page) => {
pageNo.value = page
await getImageList(false)
}
/** 暴露组件方法 */
defineExpose({ getImageList })
// emits
const emits = defineEmits(['onRegeneration']) const emits = defineEmits(['onRegeneration'])
/** 组件挂在的时候 */ /** 组件挂在的时候 */
...@@ -168,19 +173,20 @@ onMounted(async () => { ...@@ -168,19 +173,20 @@ onMounted(async () => {
// 获取 image 列表 // 获取 image 列表
await getImageList() await getImageList()
// 自动刷新 image 列表 // 自动刷新 image 列表
imageListInterval.value = setInterval(async () => { inProgressTimer.value = setInterval(async () => {
await refreshWatchImages() await refreshWatchImages()
}, 1000 * 3) }, 1000 * 3)
}) })
/** 组件取消挂在的时候 */ /** 组件取消挂在的时候 */
onUnmounted(async () => { onUnmounted(async () => {
if (imageListInterval.value) { if (inProgressTimer.value) {
clearInterval(imageListInterval.value) clearInterval(inProgressTimer.value)
} }
}) })
</script> </script>
<!-- TODO fan:这 2 个 scss 可以合并么? -->
<style lang="scss"> <style lang="scss">
.task-card { .task-card {
margin: 0; margin: 0;
...@@ -197,8 +203,7 @@ onUnmounted(async () => { ...@@ -197,8 +203,7 @@ onUnmounted(async () => {
align-content: flex-start; align-content: flex-start;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
padding: 20px; padding: 20px 20px 140px;
padding-bottom: 140px;
box-sizing: border-box; /* 确保内边距不会增加高度 */ box-sizing: border-box; /* 确保内边距不会增加高度 */
> div { > div {
...@@ -224,7 +229,6 @@ onUnmounted(async () => { ...@@ -224,7 +229,6 @@ onUnmounted(async () => {
align-items: center; align-items: center;
} }
</style> </style>
<style scoped lang="scss"> <style scoped lang="scss">
.dr-task { .dr-task {
width: 100%; width: 100%;
......
...@@ -3,12 +3,11 @@ ...@@ -3,12 +3,11 @@
<div class="prompt"> <div class="prompt">
<el-text tag="b">画面描述</el-text> <el-text tag="b">画面描述</el-text>
<el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text> <el-text tag="p">建议使用“形容词+动词+风格”的格式,使用“,”隔开</el-text>
<!-- TODO @fan:style 看看能不能哟 unocss 替代 -->
<el-input <el-input
v-model="prompt" v-model="prompt"
maxlength="1024" maxlength="1024"
rows="5" rows="5"
style="width: 100%; margin-top: 15px;" class="w-100% mt-15px"
input-style="border-radius: 7px;" input-style="border-radius: 7px;"
placeholder="例如:童话里的小屋应该是什么样子?" placeholder="例如:童话里的小屋应该是什么样子?"
show-word-limit show-word-limit
...@@ -20,12 +19,13 @@ ...@@ -20,12 +19,13 @@
<el-text tag="b">随机热词</el-text> <el-text tag="b">随机热词</el-text>
</div> </div>
<el-space wrap class="word-list"> <el-space wrap class="word-list">
<el-button round <el-button
class="btn" round
:type="(selectHotWord === hotWord ? 'primary' : 'default')" class="btn"
v-for="hotWord in hotWords" :type="selectHotWord === hotWord ? 'primary' : 'default'"
:key="hotWord" v-for="hotWord in ImageHotWords"
@click="handleHotWordClick(hotWord)" :key="hotWord"
@click="handleHotWordClick(hotWord)"
> >
{{ hotWord }} {{ hotWord }}
</el-button> </el-button>
...@@ -38,16 +38,11 @@ ...@@ -38,16 +38,11 @@
<el-space wrap class="model-list"> <el-space wrap class="model-list">
<div <div
:class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'" :class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
v-for="model in models" v-for="model in Dall3Models"
:key="model.key" :key="model.key"
> >
<el-image <el-image :src="model.image" fit="contain" @click="handleModelClick(model)" />
:src="model.image" <div class="model-font">{{ model.name }}</div>
fit="contain"
@click="handleModelClick(model)"
/>
<div class="model-font">{{model.name}}</div>
</div> </div>
</el-space> </el-space>
</div> </div>
...@@ -57,16 +52,12 @@ ...@@ -57,16 +52,12 @@
</div> </div>
<el-space wrap class="image-style-list"> <el-space wrap class="image-style-list">
<div <div
:class="selectImageStyle === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'" :class="style === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'"
v-for="imageStyle in imageStyleList" v-for="imageStyle in Dall3StyleList"
:key="imageStyle.key" :key="imageStyle.key"
> >
<el-image <el-image :src="imageStyle.image" fit="contain" @click="handleStyleClick(imageStyle)" />
:src="imageStyle.image" <div class="style-font">{{ imageStyle.name }}</div>
fit="contain"
@click="handleStyleClick(imageStyle)"
/>
<div class="style-font">{{imageStyle.name}}</div>
</div> </div>
</el-space> </el-space>
</div> </div>
...@@ -75,11 +66,15 @@ ...@@ -75,11 +66,15 @@
<el-text tag="b">画面比例</el-text> <el-text tag="b">画面比例</el-text>
</div> </div>
<el-space wrap class="size-list"> <el-space wrap class="size-list">
<div class="size-item" <div
v-for="imageSize in imageSizeList" class="size-item"
:key="imageSize.key" v-for="imageSize in Dall3SizeList"
@click="handleSizeClick(imageSize)"> :key="imageSize.key"
<div :class="selectImageSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"> @click="handleSizeClick(imageSize)"
>
<div
:class="selectSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'"
>
<div :style="imageSize.style"></div> <div :style="imageSize.style"></div>
</div> </div>
<div class="size-font">{{ imageSize.name }}</div> <div class="size-font">{{ imageSize.name }}</div>
...@@ -87,126 +82,60 @@ ...@@ -87,126 +82,60 @@
</el-space> </el-space>
</div> </div>
<div class="btns"> <div class="btns">
<el-button type="primary" <el-button type="primary" size="large" round :loading="drawIn" @click="handleGenerateImage">
size="large" {{ drawIn ? '生成中' : '生成内容' }}
round
:loading="drawIn"
@click="handleGenerateImage">
{{drawIn ? '生成中' : '生成内容'}}
</el-button> </el-button>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ImageApi, ImageDrawReqVO, ImageVO} from '@/api/ai/image'; import { ImageApi, ImageDrawReqVO, ImageVO } from '@/api/ai/image'
import {
// image 模型 Dall3Models,
interface ImageModelVO { Dall3StyleList,
key: string ImageHotWords,
name: string Dall3SizeList,
image: string ImageModelVO,
} AiPlatformEnum
} from '@/views/ai/utils/constants'
// image 大小
interface ImageSizeVO { const message = useMessage() // 消息弹窗
key: string
name: string,
style: string,
width: string,
height: string,
}
// 定义属性 // 定义属性
const prompt = ref<string>('') // 提示词 const prompt = ref<string>('') // 提示词
const drawIn = ref<boolean>(false) // 生成中 const drawIn = ref<boolean>(false) // 生成中
const selectHotWord = ref<string>('') // 选中的热词 const selectHotWord = ref<string>('') // 选中的热词
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) // 热词
const selectModel = ref<string>('dall-e-3') // 模型 const selectModel = ref<string>('dall-e-3') // 模型
// message const selectSize = ref<string>('1024x1024') // 选中 size
const message = useMessage() const style = ref<string>('vivid') // style 样式
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 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) => { const handleHotWordClick = async (hotWord: string) => {
// 取消选中 // 情况一:取消选中
if (selectHotWord.value == hotWord) { if (selectHotWord.value == hotWord) {
selectHotWord.value = '' selectHotWord.value = ''
return return
} }
// 选中
// 情况二:选中
selectHotWord.value = hotWord selectHotWord.value = hotWord
// 替换提示词
prompt.value = hotWord prompt.value = hotWord
} }
/** 模型 - click */ /** 选择 model 模型 */
const handleModelClick = async (model: ImageModelVO) => { const handleModelClick = async (model: ImageModelVO) => {
selectModel.value = model.key selectModel.value = model.key
} }
/** 样式 - click */ /** 选择 style 样式 */
const handleStyleClick = async (imageStyle: ImageModelVO) => { const handleStyleClick = async (imageStyle: ImageModelVO) => {
selectImageStyle.value = imageStyle.key style.value = imageStyle.key
} }
/** size - click */ /** 选择 size 大小 */
const handleSizeClick = async (imageSize: ImageSizeVO) => { const handleSizeClick = async (imageSize: ImageSizeVO) => {
selectImageSize.value = imageSize.key selectSize.value = imageSize.key
} }
/** 图片生产 */ /** 图片生产 */
...@@ -217,42 +146,41 @@ const handleGenerateImage = async () => { ...@@ -217,42 +146,41 @@ const handleGenerateImage = async () => {
// 加载中 // 加载中
drawIn.value = true drawIn.value = true
// 回调 // 回调
emits('onDrawStart', selectModel.value) emits('onDrawStart', AiPlatformEnum.OPENAI)
const imageSize = imageSizeList.value.find(item => item.key === selectImageSize.value) as ImageSizeVO const imageSize = Dall3SizeList.find((item) => item.key === selectSize.value) as ImageSizeVO
const form = { const form = {
platform: 'OpenAI', platform: AiPlatformEnum.OPENAI,
prompt: prompt.value, // 提示词 prompt: prompt.value, // 提示词
model: selectModel.value, // 模型 model: selectModel.value, // 模型
width: imageSize.width, // size 不能为空 width: imageSize.width, // size 不能为空
height: imageSize.height, // size 不能为空 height: imageSize.height, // size 不能为空
options: { options: {
style: selectImageStyle.value, // 图像生成的风格 style: style.value // 图像生成的风格
} }
} as ImageDrawReqVO } as ImageDrawReqVO
// 发送请求 // 发送请求
await ImageApi.drawImage(form) await ImageApi.drawImage(form)
} finally { } finally {
// 回调 // 回调
emits('onDrawComplete', selectModel.value) emits('onDrawComplete', AiPlatformEnum.OPENAI)
// 加载结束 // 加载结束
drawIn.value = false drawIn.value = false
} }
} }
/** 填充值 */ /** 填充值 */
const settingValues = async (imageDetail: ImageVO) => { const settingValues = async (detail: ImageVO) => {
prompt.value = imageDetail.prompt prompt.value = detail.prompt
selectModel.value = imageDetail.model selectModel.value = detail.model
// style.value = detail.options?.style
selectImageStyle.value = imageDetail.options?.style const imageSize = Dall3SizeList.find(
// (item) => item.key === `${detail.width}x${detail.height}`
const imageSize = imageSizeList.value.find(item => item.key === `${imageDetail.width}x${imageDetail.height}`) as ImageSizeVO ) as ImageSizeVO
await handleSizeClick(imageSize) await handleSizeClick(imageSize)
} }
/** 暴露组件方法 */ /** 暴露组件方法 */
defineExpose({ settingValues }) defineExpose({ settingValues })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
// 提示词 // 提示词
...@@ -309,7 +237,6 @@ defineExpose({ settingValues }) ...@@ -309,7 +237,6 @@ defineExpose({ settingValues })
} }
} }
// 样式 style // 样式 style
.image-style { .image-style {
margin-top: 30px; margin-top: 30px;
......
...@@ -12,10 +12,7 @@ ...@@ -12,10 +12,7 @@
@on-draw-start="handleDrawStart" @on-draw-start="handleDrawStart"
@on-draw-complete="handleDrawComplete" @on-draw-complete="handleDrawComplete"
/> />
<Midjourney <Midjourney v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY" ref="midjourneyRef" />
v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY"
ref="midjourneyRef"
/>
<StableDiffusion <StableDiffusion
v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION" v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
ref="stableDiffusionRef" ref="stableDiffusionRef"
...@@ -24,28 +21,26 @@ ...@@ -24,28 +21,26 @@
</div> </div>
</div> </div>
<div class="main"> <div class="main">
<ImageTask ref="imageTaskRef" @on-regeneration="handleRegeneration" /> <ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// TODO @fan:在整个挪到 /views/ai/image/index 目录。因为我想在 /views/ai/image/manager 做管理的功能,进行下区分! import ImageList from './components/ImageList.vue'
import Dall3 from './dall3/index.vue'
import Midjourney from './midjourney/index.vue'
import StableDiffusion from './stable-diffusion/index.vue'
import ImageTask from './ImageTask.vue'
import { AiPlatformEnum } from '@/views/ai/utils/constants' import { 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 imageListRef = ref<any>() // image 列表 ref
const dall3Ref = ref<any>() // openai ref const dall3Ref = ref<any>() // dall3(openai) ref
const midjourneyRef = ref<any>() // midjourney ref const midjourneyRef = ref<any>() // midjourney ref
const stableDiffusionRef = ref<any>() // stable diffusion ref const stableDiffusionRef = ref<any>() // stable diffusion ref
// 定义属性 // 定义属性
const selectPlatform = ref('StableDiffusion') const selectPlatform = ref(AiPlatformEnum.MIDJOURNEY)
const platformOptions = [ const platformOptions = [
{ {
label: 'DALL3 绘画', label: 'DALL3 绘画',
...@@ -61,35 +56,27 @@ const platformOptions = [ ...@@ -61,35 +56,27 @@ const platformOptions = [
} }
] ]
/** 绘画 - start */ /** 绘画 start */
const handleDrawStart = async (type) => { const handleDrawStart = async (platform: string) => {}
}
/** 绘画 - complete */ /** 绘画 complete */
const handleDrawComplete = async (type) => { const handleDrawComplete = async (platform: string) => {
await imageTaskRef.value.getImageList() await imageListRef.value.getImageList()
} }
/** 绘画 - 重新生成 */ /** 重新生成:将画图详情填充到对应平台 */
const handleRegeneration = async (imageDetail: ImageVO) => { const handleRegeneration = async (image: ImageVO) => {
// 切换平台 // 切换平台
selectPlatform.value = imageDetail.platform selectPlatform.value = image.platform
console.log('切换平台', imageDetail.platform) // 根据不同平台填充 image
// 根据不同平台填充 imageDetail await nextTick()
if (imageDetail.platform === AiPlatformEnum.MIDJOURNEY) { if (image.platform === AiPlatformEnum.MIDJOURNEY) {
await nextTick(async () => { midjourneyRef.value.settingValues(image)
midjourneyRef.value.settingValues(imageDetail) } else if (image.platform === AiPlatformEnum.OPENAI) {
}) dall3Ref.value.settingValues(image)
} else if (imageDetail.platform === AiPlatformEnum.OPENAI) { } else if (image.platform === AiPlatformEnum.STABLE_DIFFUSION) {
await nextTick(async () => { stableDiffusionRef.value.settingValues(image)
dall3Ref.value.settingValues(imageDetail)
})
} else if (imageDetail.platform === AiPlatformEnum.STABLE_DIFFUSION) {
await nextTick(async () => {
stableDiffusionRef.value.settingValues(imageDetail)
})
} }
} }
</script> </script>
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
<el-form-item label="角色头像" prop="avatar"> <el-form-item label="角色头像" prop="avatar">
<UploadImg v-model="formData.avatar" height="60px" width="60px" /> <UploadImg v-model="formData.avatar" height="60px" width="60px" />
</el-form-item> </el-form-item>
<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-select v-model="formData.modelId" placeholder="请选择模型" clearable>
<el-option <el-option
v-for="chatModel in chatModelList" v-for="chatModel in chatModelList"
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
/> />
</el-select> </el-select>
</el-form-item> </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-input v-model="formData.category" placeholder="请输入角色类别" />
</el-form-item> </el-form-item>
<el-form-item label="角色描述" prop="description"> <el-form-item label="角色描述" prop="description">
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
<el-form-item label="角色设定" prop="systemMessage"> <el-form-item label="角色设定" prop="systemMessage">
<el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" /> <el-input type="textarea" v-model="formData.systemMessage" placeholder="请输入角色设定" />
</el-form-item> </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-group v-model="formData.publicStatus">
<el-radio <el-radio
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
...@@ -43,10 +43,10 @@ ...@@ -43,10 +43,10 @@
</el-radio> </el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </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-input-number v-model="formData.sort" placeholder="请输入角色排序" class="!w-1/1" />
</el-form-item> </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-group v-model="formData.status">
<el-radio <el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)" v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
...@@ -97,10 +97,9 @@ const formRef = ref() // 表单 Ref ...@@ -97,10 +97,9 @@ const formRef = ref() // 表单 Ref
const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表 const chatModelList = ref([] as ChatModelVO[]) // 聊天模型列表
/** 是否【我】自己创建,私有角色 */ /** 是否【我】自己创建,私有角色 */
// TODO @fan:建议改成计算函数 computed const isUser = computed(() => {
const isUser = (type: string) => { return formType.value === 'my-create' || formType.value === 'my-update'
return type === 'my-create' || type === 'my-update' })
}
// TODO @fan:直接使用 formRules;只要隐藏掉的字段,它是不会校验的哈; // TODO @fan:直接使用 formRules;只要隐藏掉的字段,它是不会校验的哈;
const getFormRules = async (type: string) => { const getFormRules = async (type: string) => {
......
...@@ -48,3 +48,308 @@ export enum AiWriteTypeEnum { ...@@ -48,3 +48,308 @@ export enum AiWriteTypeEnum {
WRITING = 1, // 撰写 WRITING = 1, // 撰写
REPLY // 回复 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'
}
]
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
*/ */
/** 判断字符串是否包含中文 */ /** 判断字符串是否包含中文 */
export const hasChinese = async (str) => { export const hasChinese = (str: string) => {
return /[\u4e00-\u9fa5]/.test(str) return /[\u4e00-\u9fa5]/.test(str)
} }
......
...@@ -126,7 +126,6 @@ ...@@ -126,7 +126,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict' import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { CategoryApi, CategoryVO } from '@/api/bpm/category' import { CategoryApi, CategoryVO } from '@/api/bpm/category'
import CategoryForm from './CategoryForm.vue' import CategoryForm from './CategoryForm.vue'
......
...@@ -292,6 +292,7 @@ import { createImageViewer } from '@/components/ImageViewer' ...@@ -292,6 +292,7 @@ import { createImageViewer } from '@/components/ImageViewer'
import { RuleConfig } from '@/views/mall/product/spu/components/index' import { RuleConfig } from '@/views/mall/product/spu/components/index'
import { PropertyAndValues } from './index' import { PropertyAndValues } from './index'
import { ElTable } from 'element-plus' import { ElTable } from 'element-plus'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'SkuList' }) defineOptions({ name: 'SkuList' })
const message = useMessage() // 消息弹窗 const message = useMessage() // 消息弹窗
...@@ -340,11 +341,22 @@ const imagePreview = (imgUrl: string) => { ...@@ -340,11 +341,22 @@ const imagePreview = (imgUrl: string) => {
/** 批量添加 */ /** 批量添加 */
const batchAdd = () => { const batchAdd = () => {
validateProperty()
formData.value!.skus!.forEach((item) => { formData.value!.skus!.forEach((item) => {
copyValueToTarget(item, skuList.value[0]) 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 */ /** 删除 sku */
const deleteSku = (row) => { const deleteSku = (row) => {
const index = formData.value!.skus!.findIndex( const index = formData.value!.skus!.findIndex(
...@@ -358,6 +370,7 @@ const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表 ...@@ -358,6 +370,7 @@ const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表
* 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。 * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
*/ */
const validateSku = () => { const validateSku = () => {
validateProperty()
let warningInfo = '请检查商品各行相关属性配置,' let warningInfo = '请检查商品各行相关属性配置,'
let validate = true // 默认通过 let validate = true // 默认通过
for (const sku of formData.value!.skus!) { for (const sku of formData.value!.skus!) {
...@@ -421,7 +434,7 @@ watch( ...@@ -421,7 +434,7 @@ watch(
const generateTableData = (propertyList: any[]) => { const generateTableData = (propertyList: any[]) => {
// 构建数据结构 // 构建数据结构
const propertyValues = propertyList.map((item) => const propertyValues = propertyList.map((item) =>
item.values.map((v) => ({ item.values.map((v: any) => ({
propertyId: item.id, propertyId: item.id,
propertyName: item.name, propertyName: item.name,
valueId: v.id, valueId: v.id,
...@@ -464,15 +477,14 @@ const generateTableData = (propertyList: any[]) => { ...@@ -464,15 +477,14 @@ const generateTableData = (propertyList: any[]) => {
*/ */
const validateData = (propertyList: any[]) => { const validateData = (propertyList: any[]) => {
const skuPropertyIds: number[] = [] const skuPropertyIds: number[] = []
formData.value!.skus!.forEach( formData.value!.skus!.forEach((sku) =>
(sku) => sku.properties
sku.properties ?.map((property) => property.propertyId)
?.map((property) => property.propertyId) ?.forEach((propertyId) => {
?.forEach((propertyId) => { if (skuPropertyIds.indexOf(propertyId!) === -1) {
if (skuPropertyIds.indexOf(propertyId!) === -1) { skuPropertyIds.push(propertyId!)
skuPropertyIds.push(propertyId!) }
} })
})
) )
const propertyIds = propertyList.map((item) => item.id) const propertyIds = propertyList.map((item) => item.id)
return skuPropertyIds.length === propertyIds.length return skuPropertyIds.length === propertyIds.length
...@@ -543,7 +555,7 @@ watch( ...@@ -543,7 +555,7 @@ watch(
return return
} }
// 添加新属性没有属性值也不做处理 // 添加新属性没有属性值也不做处理
if (propertyList.some((item) => item.values!.length === 0)) { if (propertyList.some((item) => !item.values || isEmpty(item.values))) {
return return
} }
// 生成 table 数据,即 sku 列表 // 生成 table 数据,即 sku 列表
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<el-col v-for="(item, index) in attributeList" :key="index"> <el-col v-for="(item, index) in attributeList" :key="index">
<div> <div>
<el-text class="mx-1">属性名:</el-text> <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 }} {{ item.name }}
</el-tag> </el-tag>
</div> </div>
...@@ -12,8 +12,8 @@ ...@@ -12,8 +12,8 @@
<el-tag <el-tag
v-for="(value, valueIndex) in item.values" v-for="(value, valueIndex) in item.values"
:key="value.id" :key="value.id"
class="mx-1"
:closable="!isDetail" :closable="!isDetail"
class="mx-1"
@close="handleCloseValue(index, valueIndex)" @close="handleCloseValue(index, valueIndex)"
> >
{{ value.name }} {{ value.name }}
...@@ -44,7 +44,6 @@ ...@@ -44,7 +44,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ElInput } from 'element-plus' import { ElInput } from 'element-plus'
import * as PropertyApi from '@/api/mall/product/property' import * as PropertyApi from '@/api/mall/product/property'
import { PropertyVO } from '@/api/mall/product/property'
import { PropertyAndValues } from '@/views/mall/product/spu/components' import { PropertyAndValues } from '@/views/mall/product/spu/components'
import { propTypes } from '@/utils/propTypes' import { propTypes } from '@/utils/propTypes'
...@@ -59,9 +58,9 @@ const inputVisible = computed(() => (index: number) => { ...@@ -59,9 +58,9 @@ const inputVisible = computed(() => (index: number) => {
if (attributeIndex.value === null) return false if (attributeIndex.value === null) return false
if (attributeIndex.value === index) return true if (attributeIndex.value === index) return true
}) })
const inputRef = ref([]) //标签输入框Ref const inputRef = ref<any[]>([]) //标签输入框Ref
/** 解决 ref 在 v-for 中的获取问题*/ /** 解决 ref 在 v-for 中的获取问题*/
const setInputRef = (el) => { const setInputRef = (el: any) => {
if (el === null || typeof el === 'undefined') return if (el === null || typeof el === 'undefined') return
// 如果不存在 id 相同的元素才添加 // 如果不存在 id 相同的元素才添加
if (!inputRef.value.some((item) => item.input?.attributes.id === el.input?.attributes.id)) { if (!inputRef.value.some((item) => item.input?.attributes.id === el.input?.attributes.id)) {
...@@ -81,7 +80,7 @@ watch( ...@@ -81,7 +80,7 @@ watch(
() => props.propertyList, () => props.propertyList,
(data) => { (data) => {
if (!data) return if (!data) return
attributeList.value = data attributeList.value = data as any
}, },
{ {
deep: true, deep: true,
...@@ -97,6 +96,7 @@ const handleCloseValue = (index: number, valueIndex: number) => { ...@@ -97,6 +96,7 @@ const handleCloseValue = (index: number, valueIndex: number) => {
/** 删除属性*/ /** 删除属性*/
const handleCloseProperty = (index: number) => { const handleCloseProperty = (index: number) => {
attributeList.value?.splice(index, 1) attributeList.value?.splice(index, 1)
emit('success', attributeList.value)
} }
/** 显示输入框并获取焦点 */ /** 显示输入框并获取焦点 */
......
<!-- 商品发布 - 库存价格 --> <!-- 商品发布 - 库存价格 -->
<template> <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-form-item label="分销类型" props="subCommissionType">
<el-radio-group <el-radio-group
v-model="formData.subCommissionType" v-model="formData.subCommissionType"
@change="changeSubCommissionType"
class="w-80" class="w-80"
@change="changeSubCommissionType"
> >
<el-radio :label="false">默认设置</el-radio> <el-radio :label="false">默认设置</el-radio>
<el-radio :label="true" class="radio">单独设置</el-radio> <el-radio :label="true" class="radio">单独设置</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="商品规格" props="specType"> <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="false" class="radio">单规格</el-radio>
<el-radio :label="true">多规格</el-radio> <el-radio :label="true">多规格</el-radio>
</el-radio-group> </el-radio-group>
...@@ -29,22 +29,22 @@ ...@@ -29,22 +29,22 @@
<el-form-item v-if="formData.specType" label="商品属性"> <el-form-item v-if="formData.specType" label="商品属性">
<el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button> <el-button class="mb-10px mr-15px" @click="attributesAddFormRef.open">添加属性</el-button>
<ProductAttributes <ProductAttributes
:is-detail="isDetail"
:property-list="propertyList" :property-list="propertyList"
@success="generateSkus" @success="generateSkus"
:is-detail="isDetail"
/> />
</el-form-item> </el-form-item>
<template v-if="formData.specType && propertyList.length > 0"> <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" /> <SkuList :is-batch="true" :prop-form-data="formData" :property-list="propertyList" />
</el-form-item> </el-form-item>
<el-form-item label="规格列表"> <el-form-item label="规格列表">
<SkuList <SkuList
ref="skuListRef" ref="skuListRef"
:is-detail="isDetail"
:prop-form-data="formData" :prop-form-data="formData"
:property-list="propertyList" :property-list="propertyList"
:rule-config="ruleConfig" :rule-config="ruleConfig"
:is-detail="isDetail"
/> />
</el-form-item> </el-form-item>
</template> </template>
...@@ -181,7 +181,7 @@ const onChangeSpec = () => { ...@@ -181,7 +181,7 @@ const onChangeSpec = () => {
} }
/** 调用 SkuList generateTableData 方法*/ /** 调用 SkuList generateTableData 方法*/
const generateSkus = (propertyList) => { const generateSkus = (propertyList: any[]) => {
skuListRef.value.generateTableData(propertyList) skuListRef.value.generateTableData(propertyList)
} }
</script> </script>
...@@ -4,9 +4,16 @@ ...@@ -4,9 +4,16 @@
<div class="kefu-title">{{ keFuConversation.userNickname }}</div> <div class="kefu-title">{{ keFuConversation.userNickname }}</div>
</el-header> </el-header>
<el-main class="kefu-content" style="overflow: visible"> <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 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 class="flex justify-center items-center mb-20px">
<!-- 日期 --> <!-- 日期 -->
<div <div
...@@ -48,6 +55,10 @@ ...@@ -48,6 +55,10 @@
<TextMessageItem :message="item" /> <TextMessageItem :message="item" />
<!-- 图片消息 --> <!-- 图片消息 -->
<ImageMessageItem :message="item" /> <ImageMessageItem :message="item" />
<!-- 商品消息 -->
<ProductMessageItem :message="item" />
<!-- 订单消息 -->
<OrderMessageItem :message="item" />
</div> </div>
<el-avatar <el-avatar
v-if="item.senderType === UserTypeEnum.ADMIN" v-if="item.senderType === UserTypeEnum.ADMIN"
...@@ -58,6 +69,14 @@ ...@@ -58,6 +69,14 @@
</div> </div>
</div> </div>
</el-scrollbar> </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-main>
<el-footer height="230px"> <el-footer height="230px">
<div class="h-[100%]"> <div class="h-[100%]">
...@@ -86,6 +105,8 @@ import EmojiSelectPopover from './tools/EmojiSelectPopover.vue' ...@@ -86,6 +105,8 @@ import EmojiSelectPopover from './tools/EmojiSelectPopover.vue'
import PictureSelectUpload from './tools/PictureSelectUpload.vue' import PictureSelectUpload from './tools/PictureSelectUpload.vue'
import TextMessageItem from './message/TextMessageItem.vue' import TextMessageItem from './message/TextMessageItem.vue'
import ImageMessageItem from './message/ImageMessageItem.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 { Emoji } from './tools/emoji'
import { KeFuMessageContentTypeEnum } from './tools/constants' import { KeFuMessageContentTypeEnum } from './tools/constants'
import { isEmpty } from '@/utils/is' import { isEmpty } from '@/utils/is'
...@@ -101,23 +122,47 @@ const messageTool = useMessage() ...@@ -101,23 +122,47 @@ const messageTool = useMessage()
const message = ref('') // 消息 const message = ref('') // 消息
const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表 const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话 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) => { const getMessageList = async (conversation: KeFuConversationRespVO) => {
keFuConversation.value = conversation keFuConversation.value = conversation
const { list } = await KeFuMessageApi.getKeFuMessagePage({ queryParams.conversationId = conversation.id
pageNo: 1, const messageTotal = messageList.value.length
conversationId: conversation.id if (total.value > 0 && messageTotal > 0 && messageTotal === total.value) {
}) return
messageList.value = list.reverse() }
// TODO puhui999: 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动 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() 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) { if (!keFuConversation.value) {
return return
} }
getMessageList(keFuConversation.value)
queryParams.pageNo = 1
await getMessageList(keFuConversation.value)
if (loadHistory.value) {
// 有下角显示有新消息提示
showNewMessageTip.value = true
}
} }
defineExpose({ getMessageList, refreshMessageList }) defineExpose({ getMessageList, refreshMessageList })
// 是否显示聊天区域 // 是否显示聊天区域
...@@ -140,7 +185,7 @@ const handleSendPicture = async (picUrl: string) => { ...@@ -140,7 +185,7 @@ const handleSendPicture = async (picUrl: string) => {
const handleSendMessage = async () => { const handleSendMessage = async () => {
// 1. 校验消息是否为空 // 1. 校验消息是否为空
if (isEmpty(unref(message.value))) { if (isEmpty(unref(message.value))) {
messageTool.warning('请输入消息后再发送哦!') messageTool.notifyWarning('请输入消息后再发送哦!')
return return
} }
// 2. 组织发送消息 // 2. 组织发送消息
...@@ -167,12 +212,41 @@ const innerRef = ref<HTMLDivElement>() ...@@ -167,12 +212,41 @@ const innerRef = ref<HTMLDivElement>()
const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>() const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
// 滚动到底部 // 滚动到底部
const scrollToBottom = async () => { const scrollToBottom = async () => {
// 1. 滚动到最新消息 // 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
if (loadHistory.value) {
return
}
// 2.1 滚动到最新消息,关闭新消息提示
await nextTick() await nextTick()
scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight) scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
// 2. 消息已读 showNewMessageTip.value = false
// 2.2 消息已读
await KeFuMessageApi.updateKeFuMessageReadStatus(keFuConversation.value.id) 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 - 数据 * @param {*} item - 数据
...@@ -196,6 +270,32 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => { ...@@ -196,6 +270,32 @@ const showTime = computed(() => (item: KeFuMessageRespVO, index: number) => {
} }
&-content { &-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 { .ss-row-left {
justify-content: flex-start; justify-content: flex-start;
......
<template> <template>
<div class="kefu"> <div class="kefu">
<!-- TODO @puhui999:item => conversation 会不会更容易理解 -->
<div <div
v-for="(item, index) in conversationList" v-for="(item, index) in conversationList"
:key="item.id" :key="item.id"
...@@ -9,7 +10,9 @@ ...@@ -9,7 +10,9 @@
@contextmenu.prevent="rightClick($event as PointerEvent, item)" @contextmenu.prevent="rightClick($event as PointerEvent, item)"
> >
<div class="flex justify-center items-center w-100%"> <div class="flex justify-center items-center w-100%">
<!-- TODO style 换成 unocss -->
<div class="flex justify-center items-center" style="width: 50px; height: 50px"> <div class="flex justify-center items-center" style="width: 50px; height: 50px">
<!-- 头像 + 未读 -->
<el-badge <el-badge
:hidden="item.adminUnreadMessageCount === 0" :hidden="item.adminUnreadMessageCount === 0"
:max="99" :max="99"
...@@ -41,7 +44,8 @@ ...@@ -41,7 +44,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 通过右击获取到的坐标定位 -->
<!-- 右键,进行操作(类似微信) -->
<ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul"> <ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
<li <li
v-show="!selectedConversation.adminPinned" v-show="!selectedConversation.adminPinned"
...@@ -74,45 +78,34 @@ ...@@ -74,45 +78,34 @@
<script lang="ts" setup> <script lang="ts" setup>
import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation' import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { useEmoji } from './tools/emoji' import { useEmoji } from './tools/emoji'
import { formatDate, getNowDateTime } from '@/utils/formatTime' import { formatDate } from '@/utils/formatTime'
import { KeFuMessageContentTypeEnum } from './tools/constants' import { KeFuMessageContentTypeEnum } from './tools/constants'
defineOptions({ name: 'KeFuConversationBox' }) defineOptions({ name: 'KeFuConversationBox' })
const message = useMessage()
const message = useMessage() // 消息弹窗
const { replaceEmoji } = useEmoji() const { replaceEmoji } = useEmoji()
const activeConversationIndex = ref(-1) // 选中的会话
const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表 const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表
const activeConversationIndex = ref(-1) // 选中的会话 index 位置 TODO @puhui999:这个可以改成 activeConversationId 么?因为一般是选中的对话编号
/** 加载会话列表 */
const getConversationList = async () => { const getConversationList = async () => {
conversationList.value = await KeFuConversationApi.getConversationList() 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 }) defineExpose({ getConversationList })
/** 打开右侧的消息列表 */
const emits = defineEmits<{ const emits = defineEmits<{
(e: 'change', v: KeFuConversationRespVO): void (e: 'change', v: KeFuConversationRespVO): void
}>() }>()
// 打开右侧消息
const openRightMessage = (item: KeFuConversationRespVO, index: number) => { const openRightMessage = (item: KeFuConversationRespVO, index: number) => {
activeConversationIndex.value = index activeConversationIndex.value = index
emits('change', item) emits('change', item)
} }
// 获得消息类型
// TODO @puhui999:这个,是不是改成 getConversationDisplayText,获取会话的展示文本。然后,把文本消息类型,也统一处理(包括上面的 replaceEmoji)。这样,更统一。
/** 获得消息类型 */
const getContentType = computed(() => (lastMessageContentType: number) => { const getContentType = computed(() => (lastMessageContentType: number) => {
switch (lastMessageContentType) { switch (lastMessageContentType) {
case KeFuMessageContentTypeEnum.SYSTEM: case KeFuMessageContentTypeEnum.SYSTEM:
...@@ -135,8 +128,9 @@ const getContentType = computed(() => (lastMessageContentType: number) => { ...@@ -135,8 +128,9 @@ const getContentType = computed(() => (lastMessageContentType: number) => {
//======================= 右键菜单 ======================= //======================= 右键菜单 =======================
const showRightMenu = ref(false) // 显示右键菜单 const showRightMenu = ref(false) // 显示右键菜单
const rightMenuStyle = ref<any>({}) // 右键菜单 Style 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) => { const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
selectedConversation.value = item selectedConversation.value = item
// 显示右键菜单 // 显示右键菜单
...@@ -146,24 +140,25 @@ const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => { ...@@ -146,24 +140,25 @@ const rightClick = (mouseEvent: PointerEvent, item: KeFuConversationRespVO) => {
left: mouseEvent.clientX - 80 + 'px' left: mouseEvent.clientX - 80 + 'px'
} }
} }
// 关闭菜单 /** 关闭右键菜单 */
const closeRightMenu = () => { const closeRightMenu = () => {
showRightMenu.value = false showRightMenu.value = false
} }
// 置顶会话
/** 置顶会话 */
const updateConversationPinned = async (adminPinned: boolean) => { const updateConversationPinned = async (adminPinned: boolean) => {
// 1. 会话置顶/取消置顶 // 1. 会话置顶/取消置顶
await KeFuConversationApi.updateConversationPinned({ await KeFuConversationApi.updateConversationPinned({
id: selectedConversation.value.id, id: selectedConversation.value.id,
adminPinned adminPinned
}) })
// TODO puhui999: 快速操作两次提示只会提示一次看看怎么优雅解决 message.notifySuccess(adminPinned ? '置顶成功' : '取消置顶成功')
message.success(adminPinned ? '置顶成功' : '取消置顶成功')
// 2. 关闭右键菜单,更新会话列表 // 2. 关闭右键菜单,更新会话列表
closeRightMenu() closeRightMenu()
await getConversationList() await getConversationList()
} }
// 删除会话
/** 删除会话 */
const deleteConversation = async () => { const deleteConversation = async () => {
// 1. 删除会话 // 1. 删除会话
await message.confirm('您确定要删除该会话吗?') await message.confirm('您确定要删除该会话吗?')
...@@ -172,6 +167,8 @@ const deleteConversation = async () => { ...@@ -172,6 +167,8 @@ const deleteConversation = async () => {
closeRightMenu() closeRightMenu()
await getConversationList() await getConversationList()
} }
/** 监听右键菜单的显示状态,添加点击事件监听器 */
watch(showRightMenu, (val) => { watch(showRightMenu, (val) => {
if (val) { if (val) {
document.body.addEventListener('click', closeRightMenu) document.body.addEventListener('click', closeRightMenu)
......
<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>
<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>
<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>
...@@ -9,6 +9,7 @@ export const KeFuMessageContentTypeEnum = { ...@@ -9,6 +9,7 @@ export const KeFuMessageContentTypeEnum = {
PRODUCT: 10, // 商品消息 PRODUCT: 10, // 商品消息
ORDER: 11 // 订单消息" ORDER: 11 // 订单消息"
} }
// Promotion 的 WebSocket 消息类型枚举类 // Promotion 的 WebSocket 消息类型枚举类
export const WebSocketMessageTypeConstants = { export const WebSocketMessageTypeConstants = {
KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型 KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
......
<template> <template>
<el-row :gutter="10"> <el-row :gutter="10">
<!-- TODO @puhui999:KeFuConversationBox => KeFuConversationList ;KeFuChatBox => KeFuMessageList -->
<!-- 会话列表 -->
<el-col :span="8"> <el-col :span="8">
<ContentWrap> <ContentWrap>
<KeFuConversationBox ref="keFuConversationRef" @change="handleChange" /> <KeFuConversationBox ref="keFuConversationRef" @change="handleChange" />
</ContentWrap> </ContentWrap>
</el-col> </el-col>
<!-- 会话详情(选中会话的消息列表) -->
<el-col :span="16"> <el-col :span="16">
<ContentWrap> <ContentWrap>
<KeFuChatBox ref="keFuChatBoxRef" @change="getConversationList" /> <KeFuChatBox ref="keFuChatBoxRef" @change="getConversationList" />
...@@ -21,15 +24,10 @@ import { getAccessToken } from '@/utils/auth' ...@@ -21,15 +24,10 @@ import { getAccessToken } from '@/utils/auth'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
defineOptions({ name: 'KeFu' }) defineOptions({ name: 'KeFu' })
const message = useMessage()
// 加载消息 const message = useMessage() // 消息弹窗
const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
const handleChange = (conversation: KeFuConversationRespVO) => {
keFuChatBoxRef.value?.getMessageList(conversation)
}
//======================= websocket start======================= // ======================= WebSocket start =======================
const server = ref( const server = ref(
(import.meta.env.VITE_BASE_URL + '/infra/ws/').replace('http', 'ws') + (import.meta.env.VITE_BASE_URL + '/infra/ws/').replace('http', 'ws') +
'?token=' + '?token=' +
...@@ -38,9 +36,11 @@ const server = ref( ...@@ -38,9 +36,11 @@ const server = ref(
/** 发起 WebSocket 连接 */ /** 发起 WebSocket 连接 */
const { data, close, open } = useWebSocket(server.value, { const { data, close, open } = useWebSocket(server.value, {
autoReconnect: false, autoReconnect: false, // TODO @puhui999:重连要加下
heartbeat: true heartbeat: true
}) })
/** 监听 WebSocket 数据 */
watchEffect(() => { watchEffect(() => {
if (!data.value) { if (!data.value) {
return return
...@@ -75,17 +75,28 @@ watchEffect(() => { ...@@ -75,17 +75,28 @@ watchEffect(() => {
console.error(error) console.error(error)
} }
}) })
//======================= websocket end======================= // ======================= WebSocket end =======================
// 加载会话列表
/** 加载会话列表 */
const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>() const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>()
const getConversationList = () => { const getConversationList = () => {
keFuConversationRef.value?.getConversationList() keFuConversationRef.value?.getConversationList()
} }
/** 加载指定会话的消息列表 */
const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
const handleChange = (conversation: KeFuConversationRespVO) => {
keFuChatBoxRef.value?.getMessageList(conversation)
}
/** 初始化 */
onMounted(() => { onMounted(() => {
getConversationList() getConversationList()
// 打开 websocket 连接 // 打开 websocket 连接
open() open()
}) })
/** 销毁 */
onBeforeUnmount(() => { onBeforeUnmount(() => {
// 关闭 websocket 连接 // 关闭 websocket 连接
close() close()
...@@ -104,17 +115,17 @@ onBeforeUnmount(() => { ...@@ -104,17 +115,17 @@ onBeforeUnmount(() => {
height: 6px; height: 6px;
} }
/*定义滚动条轨道 内阴影+圆角*/ /* 定义滚动条轨道 内阴影+圆角 */
::-webkit-scrollbar-track { ::-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; border-radius: 10px;
background-color: #fff; background-color: #fff;
} }
/*定义滑块 内阴影+圆角*/ /* 定义滑块 内阴影+圆角 */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
border-radius: 10px; 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); background-color: rgba(240, 240, 240, 0.5);
} }
</style> </style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment