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

This commit is contained in:
hhhero
2024-07-07 18:15:00 +08:00
134 changed files with 3484 additions and 820 deletions

View File

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

View File

@@ -1,11 +1,10 @@
<!-- AI 对话 -->
<template>
<el-aside width="260px" class="conversation-container" style="height: 100%;">
<el-aside width="260px" class="conversation-container" style="height: 100%">
<!-- 左顶部对话 -->
<div style="height: 100%;">
<div style="height: 100%">
<el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
<Icon icon="ep:plus" class="mr-5px"/>
<Icon icon="ep:plus" class="mr-5px" />
新建对话
</el-button>
@@ -18,17 +17,19 @@
@keyup="searchConversation"
>
<template #prefix>
<Icon icon="ep:search"/>
<Icon icon="ep:search" />
</template>
</el-input>
<!-- 左中间对话列表 -->
<div class="conversation-list">
<el-empty v-if="loading" description="." :v-loading="loading" />
<div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
<div class="conversation-item classify-title" v-if="conversationMap[conversationKey].length">
<div
class="conversation-item classify-title"
v-if="conversationMap[conversationKey].length"
>
<el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
</div>
<div
@@ -40,25 +41,27 @@
@mouseout="hoverConversationId = ''"
>
<div
:class="conversation.id === activeConversationId ? 'conversation active' : 'conversation'"
:class="
conversation.id === activeConversationId ? 'conversation active' : 'conversation'
"
>
<div class="title-wrapper">
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg"/>
<img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
<span class="title">{{ conversation.title }}</span>
</div>
<div class="button-wrapper" v-show="hoverConversationId === conversation.id">
<el-button class="btn" link @click.stop="handlerTop(conversation)" >
<el-button class="btn" link @click.stop="handlerTop(conversation)">
<el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
<el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
</el-button>
<el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
<el-icon title="编辑" >
<Icon icon="ep:edit"/>
<el-icon title="编辑">
<Icon icon="ep:edit" />
</el-icon>
</el-button>
<el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
<el-icon title="删除对话" >
<Icon icon="ep:delete"/>
<el-icon title="删除对话">
<Icon icon="ep:delete" />
</el-icon>
</el-button>
</div>
@@ -66,20 +69,19 @@
</div>
</div>
<!-- 底部站位 -->
<div style="height: 160px; width: 100%;"></div>
<div style="height: 160px; width: 100%"></div>
</div>
</div>
<!-- 左底部工具栏 -->
<!-- TODO @fan下面两个 icon可以使用类似 <Icon icon="ep:question-filled" /> 替代哈 -->
<div class="tool-box">
<div @click="handleRoleRepository">
<Icon icon="ep:user"/>
<Icon icon="ep:user" />
<el-text size="small">角色仓库</el-text>
</div>
<div @click="handleClearConversation">
<Icon icon="ep:delete"/>
<Icon icon="ep:delete" />
<el-text size="small">清空未置顶对话</el-text>
</div>
</div>
@@ -88,17 +90,16 @@
<!-- 角色仓库抽屉 -->
<el-drawer v-model="drawer" title="角色仓库" size="754px">
<Role/>
<Role />
</el-drawer>
</el-aside>
</template>
<script setup lang="ts">
import {ChatConversationApi, ChatConversationVO} from '@/api/ai/chat/conversation'
import {ref} from "vue";
import Role from "@/views/ai/chat/role/index.vue";
import {Bottom, Top} from "@element-plus/icons-vue";
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
import { ref } from 'vue'
import Role from './role/index.vue'
import { Bottom, Top } from '@element-plus/icons-vue'
import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
const message = useMessage() //
@@ -107,8 +108,8 @@ const message = useMessage() // 消息弹窗
const searchName = ref<string>('') //
const activeConversationId = ref<string | null>(null) // null
const hoverConversationId = ref<string | null>(null) //
const conversationList = ref([] as ChatConversationVO[]) //
const conversationMap = ref<any>({}) // ()
const conversationList = ref([] as ChatConversationVO[]) //
const conversationMap = ref<any>({}) // ()
const drawer = ref<boolean>(false) // TODO @fanroleDrawer
const loading = ref<boolean>(false) //
const loadingTime = ref<any>() //
@@ -138,7 +139,7 @@ const searchConversation = async (e) => {
conversationMap.value = await conversationTimeGroup(conversationList.value)
} else {
//
const filterValues = conversationList.value.filter(item => {
const filterValues = conversationList.value.filter((item) => {
return item.title.includes(searchName.value.trim())
})
conversationMap.value = await conversationTimeGroup(filterValues)
@@ -150,7 +151,7 @@ const searchConversation = async (e) => {
*/
const handleConversationClick = async (id: string) => {
//
const filterConversation = conversationList.value.filter(item => {
const filterConversation = conversationList.value.filter((item) => {
return item.id === id
})
// onConversationClick
@@ -211,20 +212,20 @@ const getChatConversationList = async () => {
const conversationTimeGroup = async (list: ChatConversationVO[]) => {
// (30)
const groupMap = {
'置顶': [],
'今天': [],
'一天前': [],
'三天前': [],
'七天前': [],
'三十天前': []
置顶: [],
今天: [],
一天前: [],
三天前: [],
七天前: [],
三十天前: []
}
//
const now = Date.now();
const now = Date.now()
//
const oneDay = 24 * 60 * 60 * 1000;
const threeDays = 3 * oneDay;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const oneDay = 24 * 60 * 60 * 1000
const threeDays = 3 * oneDay
const sevenDays = 7 * oneDay
const thirtyDays = 30 * oneDay
for (const conversation: ChatConversationVO of list) {
//
if (conversation.pinned) {
@@ -232,7 +233,7 @@ const conversationTimeGroup = async (list: ChatConversationVO[]) => {
continue
}
//
const diff = now - conversation.updateTime;
const diff = now - conversation.updateTime
//
if (diff < oneDay) {
groupMap['今天'].push(conversation)
@@ -271,7 +272,7 @@ const createConversation = async () => {
*/
const updateConversationTitle = async (conversation: ChatConversationVO) => {
// 1.
const {value} = await ElMessageBox.prompt('修改标题', {
const { value } = await ElMessageBox.prompt('修改标题', {
inputPattern: /^[\s\S]*.*\S[\s\S]*$/, //
inputErrorMessage: '标题不能为空',
inputValue: conversation.title
@@ -285,7 +286,7 @@ const updateConversationTitle = async (conversation: ChatConversationVO) => {
// 3.
await getChatConversationList()
// 4.
const filterConversationList = conversationList.value.filter(item => {
const filterConversationList = conversationList.value.filter((item) => {
return item.id === conversation.id
})
if (filterConversationList.length > 0) {
@@ -310,8 +311,7 @@ const deleteChatConversation = async (conversation: ChatConversationVO) => {
await getChatConversationList()
//
emits('onConversationDelete', conversation)
} catch {
}
} catch {}
}
/**
@@ -343,16 +343,13 @@ const handleRoleRepository = async () => {
*/
const handleClearConversation = async () => {
// TODO @fan使 await message.confirm( 使 await
ElMessageBox.confirm(
'确认后对话会全部清空,置顶的对话除外。',
'确认提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
ElMessageBox.confirm('确认后对话会全部清空,置顶的对话除外。', '确认提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
await ChatConversationApi.deleteMyAllExceptPinned()
await ChatConversationApi.deleteChatConversationMyByUnpinned()
ElMessage({
message: '操作成功!',
type: 'success'
@@ -364,8 +361,7 @@ const handleClearConversation = async () => {
//
emits('onConversationClear')
})
.catch(() => {
})
.catch(() => {})
}
// ============ onMounted
@@ -377,7 +373,7 @@ watch(activeId, async (newValue, oldValue) => {
})
// public
defineExpose({createConversation})
defineExpose({ createConversation })
onMounted(async () => {
//
@@ -394,11 +390,9 @@ onMounted(async () => {
}
}
})
</script>
<style scoped lang="scss">
.conversation-container {
position: relative;
display: flex;

View File

@@ -111,7 +111,7 @@ import MessageLoading from './MessageLoading.vue'
import MessageNewChat from './MessageNewChat.vue'
import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
import ChatConversationUpdateForm from '@/views/ai/chat/components/ChatConversationUpdateForm.vue'
import ChatConversationUpdateForm from './components/ChatConversationUpdateForm.vue'
import { Download, Top } from '@element-plus/icons-vue'
const route = useRoute() //

View File

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

View File

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

View File

@@ -1,140 +0,0 @@
<template>
<el-card class="dr-task" body-class="task-card" shadow="never">
<template #header>绘画任务</template>
<ImageTaskCard
v-for="image in imageList"
:key="image"
:image-detail="image"
@on-btn-click="handlerImageBtnClick"
@on-mj-btn-click="handlerImageMjBtnClick"/>
</el-card>
<!-- 图片 detail 抽屉 -->
<ImageDetailDrawer
:show="isShowImageDetail"
:id="showImageDetailId"
@handler-drawer-close="handlerDrawerClose"
/>
</template>
<script setup lang="ts">
import {ImageApi, ImageDetailVO, ImageMjActionVO, ImageMjButtonsVO} from '@/api/ai/image';
import ImageDetailDrawer from './ImageDetailDrawer.vue'
import ImageTaskCard from './ImageTaskCard.vue'
const message = useMessage() // 消息弹窗
const imageList = ref<ImageDetailVO[]>([]) // image 列表
const imageListInterval = ref<any>() // image 列表定时器,刷新列表
const isShowImageDetail = ref<boolean>(false) // 是否显示 task 详情
const showImageDetailId = ref<number>(0) // 是否显示 task 详情
/** 抽屉 - close */
const handlerDrawerClose = async () => {
isShowImageDetail.value = false
}
/** 任务 - detail */
const handlerDrawerOpen = async () => {
isShowImageDetail.value = true
}
/**
* 获取 - image 列表
*/
const getImageList = async () => {
const { list } = await ImageApi.getImageList({pageNo: 1, pageSize: 20})
imageList.value = list
}
/** 图片 - btn click */
const handlerImageBtnClick = async (type, imageDetail: ImageDetailVO) => {
// 获取 image detail id
showImageDetailId.value = imageDetail.id
// 处理不用 btn
if (type === 'more') {
await handlerDrawerOpen()
} else if (type === 'delete') {
await message.confirm(`是否删除照片?`)
await ImageApi.deleteImage(imageDetail.id)
await getImageList()
await message.success("删除成功!")
} else if (type === 'download') {
await downloadImage(imageDetail.picUrl)
}
}
/** 图片 - mj btn click */
const handlerImageMjBtnClick = async (button: ImageMjButtonsVO, imageDetail: ImageDetailVO) => {
// 1、构建 params 参数
const params = {
id: imageDetail.id,
customId: button.customId,
} as ImageMjActionVO
// 2、发送 action
await ImageApi.midjourneyAction(params)
// 3、刷新列表
await getImageList()
}
/** 下载 - image */
// TODO @fan貌似可以考虑抽到 download 里面,作为一个方法
const downloadImage = async (imageUrl) => {
const image = new Image()
image.setAttribute('crossOrigin', 'anonymous')
image.src = imageUrl
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const ctx = canvas.getContext('2d') as CanvasDrawImage
ctx.drawImage(image, 0, 0, image.width, image.height)
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = 'image.png'
a.click()
}
}
/** 暴露组件方法 */
defineExpose({getImageList})
/** 组件挂在的时候 */
onMounted(async () => {
// 获取 image 列表
await getImageList()
// 自动刷新 image 列表
imageListInterval.value = setInterval(async () => {
await getImageList()
}, 1000 * 20)
})
/** 组件取消挂在的时候 */
onUnmounted(async () => {
if (imageListInterval.value) {
clearInterval(imageListInterval.value)
}
})
</script>
<style lang="scss">
.task-card {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
height: 100%;
overflow: auto;
>div {
margin-right: 20px;
margin-bottom: 20px;
}
}
</style>
<style scoped lang="scss">
.dr-task {
width: 100%;
height: 100%;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@
:type="(selectHotWord === hotWord ? 'primary' : 'default')"
v-for="hotWord in hotWords"
:key="hotWord"
@click="handlerHotWordClick(hotWord)"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</el-button>
@@ -37,7 +37,7 @@
</div>
<el-space wrap class="model-list">
<div
:class="selectModel === model ? 'modal-item selectModel' : 'modal-item'"
:class="selectModel === model.key ? 'modal-item selectModel' : 'modal-item'"
v-for="model in models"
:key="model.key"
@@ -45,7 +45,7 @@
<el-image
:src="model.image"
fit="contain"
@click="handlerModelClick(model)"
@click="handleModelClick(model)"
/>
<div class="model-font">{{model.name}}</div>
</div>
@@ -57,14 +57,14 @@
</div>
<el-space wrap class="image-style-list">
<div
:class="selectImageStyle === imageStyle ? 'image-style-item selectImageStyle' : 'image-style-item'"
:class="selectImageStyle === imageStyle.key ? 'image-style-item selectImageStyle' : 'image-style-item'"
v-for="imageStyle in imageStyleList"
:key="imageStyle.key"
>
<el-image
:src="imageStyle.image"
fit="contain"
@click="handlerStyleClick(imageStyle)"
@click="handleStyleClick(imageStyle)"
/>
<div class="style-font">{{imageStyle.name}}</div>
</div>
@@ -78,8 +78,8 @@
<div class="size-item"
v-for="imageSize in imageSizeList"
:key="imageSize.key"
@click="handlerSizeClick(imageSize)">
<div :class="selectImageSize === imageSize ? 'size-wrapper selectImageSize' : 'size-wrapper'">
@click="handleSizeClick(imageSize)">
<div :class="selectImageSize === imageSize.key ? 'size-wrapper selectImageSize' : 'size-wrapper'">
<div :style="imageSize.style"></div>
</div>
<div class="size-font">{{ imageSize.name }}</div>
@@ -91,13 +91,13 @@
size="large"
round
:loading="drawIn"
@click="handlerGenerateImage">
@click="handleGenerateImage">
{{drawIn ? '生成中' : '生成内容'}}
</el-button>
</div>
</template>
<script setup lang="ts">
import {ImageApi, ImageDrawReqVO} from '@/api/ai/image';
import {ImageApi, ImageDrawReqVO, ImageVO} from '@/api/ai/image';
// image
interface ImageModelVO {
@@ -120,40 +120,38 @@ const prompt = ref<string>('') // 提示词
const drawIn = ref<boolean>(false) //
const selectHotWord = ref<string>('') //
const hotWords = ref<string[]>(['中国旗袍', '古装美女', '卡通头像', '机甲战士', '童话小屋', '中国长城']) //
const selectModel = ref<any>({}) //
// TODO @fanimage
// TODO @fan image
const selectModel = ref<string>('dall-e-3') //
// message
const message = useMessage()
const models = ref<ImageModelVO[]>([
{
key: 'dall-e-3',
name: 'DALL·E 3',
image: 'https://h5.cxyhub.com/images/model_2.png',
image: `/src/assets/ai/dall2.jpg`,
},
{
key: 'dall-e-2',
name: 'DALL·E 2',
image: 'https://h5.cxyhub.com/images/model_1.png',
image: `/src/assets/ai/dall3.jpg`,
},
]) //
selectModel.value = models.value[0]
const selectImageStyle = ref<any>({}) // style
// TODO @fanimage
const selectImageStyle = ref<string>('vivid') // style
const imageStyleList = ref<ImageModelVO[]>([
{
key: 'vivid',
name: '清晰',
image: 'https://h5.cxyhub.com/images/model_1.png',
image: `/src/assets/ai/qingxi.jpg`,
},
{
key: 'natural',
name: '自然',
image: 'https://h5.cxyhub.com/images/model_2.png',
image: `/src/assets/ai/ziran.jpg`,
},
]) // style
selectImageStyle.value = imageStyleList.value[0]
const selectImageSize = ref<ImageSizeVO>({} as ImageSizeVO) // size
const selectImageSize = ref<string>('1024x1024') // size
const imageSizeList = ref<ImageSizeVO[]>([
{
key: '1024x1024',
@@ -177,17 +175,14 @@ const imageSizeList = ref<ImageSizeVO[]>([
style: 'width: 50px; height: 30px;background-color: #dcdcdc;',
}
]) // size
selectImageSize.value = imageSizeList.value[0]
// Props
const props = defineProps({})
// emits
const emits = defineEmits(['onDrawStart', 'onDrawComplete'])
// TODO @fan /** */~
// TODO @fanhandler handle
/** 热词 - click */
const handlerHotWordClick = async (hotWord: string) => {
const handleHotWordClick = async (hotWord: string) => {
//
if (selectHotWord.value == hotWord) {
selectHotWord.value = ''
@@ -200,62 +195,66 @@ const handlerHotWordClick = async (hotWord: string) => {
}
/** 模型 - click */
const handlerModelClick = async (model: ImageModelVO) => {
if (selectModel.value === model) {
selectModel.value = {} as ImageModelVO
return
}
selectModel.value = model
const handleModelClick = async (model: ImageModelVO) => {
selectModel.value = model.key
}
/** 样式 - click */
const handlerStyleClick = async (imageStyle: ImageModelVO) => {
if (selectImageStyle.value === imageStyle) {
selectImageStyle.value = {} as ImageModelVO
return
}
selectImageStyle.value = imageStyle
const handleStyleClick = async (imageStyle: ImageModelVO) => {
selectImageStyle.value = imageStyle.key
}
/** size - click */
const handlerSizeClick = async (imageSize: ImageSizeVO) => {
if (selectImageSize.value === imageSize) {
selectImageSize.value = {} as ImageSizeVO
return
}
selectImageSize.value = imageSize
const handleSizeClick = async (imageSize: ImageSizeVO) => {
selectImageSize.value = imageSize.key
}
/** 图片生产 */
const handlerGenerateImage = async () => {
const handleGenerateImage = async () => {
//
await message.confirm(`确认生成内容?`)
try {
//
drawIn.value = true
//
emits('onDrawStart', selectModel.value.key)
emits('onDrawStart', selectModel.value)
const imageSize = imageSizeList.value.find(item => item.key === selectImageSize.value) as ImageSizeVO
const form = {
platform: 'OpenAI',
prompt: prompt.value, //
model: selectModel.value.key, //
width: selectImageSize.value.width, // size
height: selectImageSize.value.height, // size
model: selectModel.value, //
width: imageSize.width, // size
height: imageSize.height, // size
options: {
style: selectImageStyle.value.key, //
style: selectImageStyle.value, //
}
} as ImageDrawReqVO
//
await ImageApi.drawImage(form)
} finally {
//
emits('onDrawComplete', selectModel.value.key)
emits('onDrawComplete', selectModel.value)
//
drawIn.value = false
}
}
/** 填充值 */
const settingValues = async (imageDetail: ImageVO) => {
prompt.value = imageDetail.prompt
selectModel.value = imageDetail.model
//
selectImageStyle.value = imageDetail.options?.style
//
const imageSize = imageSizeList.value.find(item => item.key === `${imageDetail.width}x${imageDetail.height}`) as ImageSizeVO
await handleSizeClick(imageSize)
}
/** 暴露组件方法 */
defineExpose({ settingValues })
</script>
<style scoped lang="scss">
//
.prompt {
}

View File

@@ -0,0 +1,142 @@
<!-- image -->
<template>
<div class="ai-image">
<div class="left">
<div class="segmented">
<el-segmented v-model="selectPlatform" :options="platformOptions" />
</div>
<div class="modal-switch-container">
<Dall3
v-if="selectPlatform === AiPlatformEnum.OPENAI"
ref="dall3Ref"
@on-draw-start="handleDrawStart"
@on-draw-complete="handleDrawComplete"
/>
<Midjourney
v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY"
ref="midjourneyRef"
/>
<StableDiffusion
v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
ref="stableDiffusionRef"
@on-draw-complete="handleDrawComplete"
/>
</div>
</div>
<div class="main">
<ImageTask ref="imageTaskRef" @on-regeneration="handleRegeneration" />
</div>
</div>
</template>
<script setup lang="ts">
// TODO @fan在整个挪到 /views/ai/image/index 目录。因为我想在 /views/ai/image/manager 做管理的功能,进行下区分!
import Dall3 from './dall3/index.vue'
import Midjourney from './midjourney/index.vue'
import StableDiffusion from './stable-diffusion/index.vue'
import ImageTask from './ImageTask.vue'
import { AiPlatformEnum } from '@/views/ai/utils/constants'
import {ImageVO} from "@/api/ai/image";
const imageTaskRef = ref<any>() // image task ref
const dall3Ref = ref<any>() // openai ref
const midjourneyRef = ref<any>() // midjourney ref
const stableDiffusionRef = ref<any>() // stable diffusion ref
// 定义属性
const selectPlatform = ref('StableDiffusion')
const platformOptions = [
{
label: 'DALL3 绘画',
value: AiPlatformEnum.OPENAI
},
{
label: 'MJ 绘画',
value: AiPlatformEnum.MIDJOURNEY
},
{
label: 'Stable Diffusion',
value: AiPlatformEnum.STABLE_DIFFUSION
}
]
/** 绘画 - start */
const handleDrawStart = async (type) => {
}
/** 绘画 - complete */
const handleDrawComplete = async (type) => {
await imageTaskRef.value.getImageList()
}
/** 绘画 - 重新生成 */
const handleRegeneration = async (imageDetail: ImageVO) => {
// 切换平台
selectPlatform.value = imageDetail.platform
console.log('切换平台', imageDetail.platform)
// 根据不同平台填充 imageDetail
if (imageDetail.platform === AiPlatformEnum.MIDJOURNEY) {
await nextTick(async () => {
midjourneyRef.value.settingValues(imageDetail)
})
} else if (imageDetail.platform === AiPlatformEnum.OPENAI) {
await nextTick(async () => {
dall3Ref.value.settingValues(imageDetail)
})
} else if (imageDetail.platform === AiPlatformEnum.STABLE_DIFFUSION) {
await nextTick(async () => {
stableDiffusionRef.value.settingValues(imageDetail)
})
}
}
</script>
<style scoped lang="scss">
.ai-image {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
.left {
display: flex;
flex-direction: column;
padding: 20px;
width: 350px;
.segmented {
}
.segmented .el-segmented {
--el-border-radius-base: 16px;
--el-segmented-item-selected-color: #fff;
background-color: #ececec;
width: 350px;
}
.modal-switch-container {
height: 100%;
overflow-y: auto;
margin-top: 30px;
}
}
.main {
flex: 1;
background-color: #fff;
}
.right {
width: 350px;
background-color: #f7f8fa;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,10 @@
<?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="1720063872285" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6895"
width="200" height="200">
<path d="M782.16 880.98c-179.31 23.91-361 23.91-540.32 0C138.89 867.25 62 779.43 62 675.57V348.43c0-103.86 76.89-191.69 179.84-205.41 179.31-23.91 361-23.91 540.31 0C885.11 156.75 962 244.57 962 348.43v327.13c0 103.87-76.89 191.69-179.84 205.42z"
fill="#FF554D" p-id="6896"></path>
<path d="M226.11 596.86c-9.74 47.83 17.26 95.6 63.48 111.3C333.49 723.08 394.55 737 469.53 737c59.25 0 105.46-8.69 140.23-19.7 51.59-16.34 79.94-71.16 63.37-122.68-24.47-76.11-65.57-180.7-106.68-180.7-64.62 0-64.62 96.92-64.62 96.92S437.22 317 372.61 317c-82.11 0-117.85 139.12-146.5 279.86z"
fill="#FFFFFF" p-id="6897"></path>
<path d="M782 347m-60 0a60 60 0 1 0 120 0 60 60 0 1 0-120 0Z" fill="#FFBC55" p-id="6898"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,4 @@
import KeFuConversationBox from './KeFuConversationBox.vue'
import KeFuChatBox from './KeFuChatBox.vue'
export { KeFuConversationBox, KeFuChatBox }

View File

@@ -0,0 +1,39 @@
<template>
<!-- 图片消息 -->
<template v-if="KeFuMessageContentTypeEnum.IMAGE === message.contentType">
<div
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: message.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
>
<el-image
:src="message.content"
fit="contain"
style="width: 200px"
@click="imagePreview(message.content)"
/>
</div>
</template>
</template>
<script lang="ts" setup>
import { KeFuMessageContentTypeEnum } from '../tools/constants'
import { UserTypeEnum } from '@/utils/constants'
import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
import { createImageViewer } from '@/components/ImageViewer'
defineOptions({ name: 'ImageMessageItem' })
defineProps<{
message: KeFuMessageRespVO
}>()
/** 图预览 */
const imagePreview = (imgUrl: string) => {
createImageViewer({
urlList: [imgUrl]
})
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<!-- 文本消息 -->
<template v-if="KeFuMessageContentTypeEnum.TEXT === message.contentType">
<div
v-dompurify-html="replaceEmoji(message.content)"
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: message.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
class="flex items-center"
></div>
</template>
</template>
<script lang="ts" setup>
import { KeFuMessageContentTypeEnum } from '../tools/constants'
import { UserTypeEnum } from '@/utils/constants'
import { useEmoji } from '../tools/emoji'
import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
defineOptions({ name: 'TextMessageItem' })
defineProps<{
message: KeFuMessageRespVO
}>()
const { replaceEmoji } = useEmoji()
</script>

View File

@@ -0,0 +1,41 @@
<!-- emoji 表情选择组件 -->
<template>
<el-popover :width="500" placement="top" trigger="click">
<template #reference>
<Icon :size="30" class="ml-10px cursor-pointer" icon="twemoji:grinning-face" />
</template>
<ElScrollbar height="300px">
<ul class="ml-2 flex flex-wrap px-2">
<li
v-for="(item, index) in emojiList"
:key="index"
:style="{
borderColor: 'var(--el-color-primary)',
color: 'var(--el-color-primary)'
}"
:title="item.name"
class="icon-item mr-2 mt-1 w-1/10 flex cursor-pointer items-center justify-center border border-solid p-2"
@click="handleSelect(item)"
>
<img :src="item.url" style="width: 24px; height: 24px" />
</li>
</ul>
</ElScrollbar>
</el-popover>
</template>
<script lang="ts" setup>
defineOptions({ name: 'EmojiSelectPopover' })
import { Emoji, useEmoji } from './emoji'
const { getEmojiList } = useEmoji()
const emojiList = computed(() => getEmojiList())
const emits = defineEmits<{
(e: 'select-emoji', v: Emoji)
}>()
const handleSelect = (item: Emoji) => {
// 整个 emoji 数据传递出去,方便以后输入框直接显示表情
emits('select-emoji', item)
}
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div>
<img :src="Picture" style="width: 35px; height: 35px" @click="selectAndUpload" />
</div>
</template>
<script lang="ts" setup>
import Picture from '@/views/mall/promotion/kefu/components/images/picture.svg'
import * as FileApi from '@/api/infra/file'
defineOptions({ name: 'PictureSelectUpload' })
const message = useMessage()
const emits = defineEmits<{
(e: 'send-picture', v: string): void
}>()
// 选择并上传文件
const selectAndUpload = async () => {
const files: any = await getFiles()
message.success('图片发送中请稍等。。。')
const res = await FileApi.updateFile({ file: files[0].file })
emits('send-picture', res.data)
}
/**
* 唤起文件选择窗口,并获取选择的文件
* @param {Object} options - 配置选项
* @param {boolean} [options.multiple=true] - 是否支持多选
* @param {string} [options.accept=''] - 文件上传格式限制
* @param {number} [options.limit=1] - 单次上传最大文件数
* @param {number} [options.fileSize=500] - 单个文件大小限制单位MB
* @returns {Promise<Array>} 选择的文件列表每个文件带有一个uid
*/
async function getFiles(options = {}) {
const { multiple, accept, limit, fileSize } = {
multiple: true,
accept: 'image/jpeg, image/png, image/gif', // 默认选择图片
limit: 1,
fileSize: 500,
...options
}
// 创建文件选择元素
const input = document.createElement('input')
input.type = 'file'
input.style.display = 'none'
if (multiple) input.multiple = true
if (accept) input.accept = accept
// 将文件选择元素添加到文档中
document.body.appendChild(input)
// 触发文件选择元素的点击事件
input.click()
// 等待文件选择元素的 change 事件
try {
const files = await new Promise((resolve, reject) => {
input.addEventListener('change', (event: any) => {
const filesArray = Array.from(event?.target?.files || [])
// 从文档中移除文件选择元素
document.body.removeChild(input)
// 判断是否超出上传数量限制
if (filesArray.length > limit) {
reject({ errorType: 'limit', files: filesArray })
return
}
// 判断是否超出上传文件大小限制
const oversizedFiles = filesArray.filter((file: File) => file.size / 1024 ** 2 > fileSize)
if (oversizedFiles.length > 0) {
reject({ errorType: 'fileSize', files: oversizedFiles })
return
}
// 生成文件列表,并添加 uid
const fileList = filesArray.map((file, index) => ({ file, uid: Date.now() + index }))
resolve(fileList)
})
})
return files
} catch (error) {
console.error('选择文件出错:', error)
throw error
}
}
</script>
<style lang="scss" scoped></style>

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