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

# Conflicts:
#	package.json
pull/484/head
hhhero 2024-07-25 20:42:26 +08:00
commit a4a376b18d
32 changed files with 769 additions and 452 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@ -191,26 +191,24 @@ ps核心功能已经实现正在对接微信小程序中...
### 商城系统
演示地址:<https://doc.iocoder.cn/mall-preview/>
![功能图](/.image/common/mall-feature.png)
![功能图](/.image/common/mall-preview.png)
_前端基于 crmeb uniapp 经过授权重构优化代码实现接入芋道快速开发平台_
演示地址:<https://doc.iocoder.cn/mall-preview/>
### ERP 系统
![功能图](/.image/common/erp-feature.png)
演示地址:<https://doc.iocoder.cn/erp-preview/>
![功能图](/.image/common/erp-feature.png)
### CRM 系统
![功能图](/.image/common/crm-feature.png)
演示地址:<https://doc.iocoder.cn/crm-preview/>
![功能图](/.image/common/crm-feature.png)
## 🐷 演示图
### 系统功能

View File

@ -56,6 +56,10 @@ export const ImageApi = {
getImagePageMy: async (params: PageParam) => {
return await request.get({ url: `/ai/image/my-page`, params })
},
// 获取公开的绘图记录
getImagePagePublic: async (params) => {
return await request.get({ url: `/ai/image/public-page`, params })
},
// 获取【我的】绘图记录
getImageMy: async (id: number) => {
return await request.get({ url: `/ai/image/get-my?id=${id}` })

View File

@ -17,7 +17,7 @@ export interface WriteVO {
platform?: string // 平台
model?: string // 模型
generatedContent?: string // 生成的内容
errorMessage: string // 错误信息
errorMessage?: string // 错误信息
createTime?: Date // 创建时间
}

View File

@ -0,0 +1,10 @@
import request from '@/config/axios'
/**
*
*
* @param params
*/
export const getBrowseHistoryPage = (params: any) => {
return request.get({ url: '/product/browse-history/page', params })
}

Binary file not shown.

View File

@ -1,10 +1,11 @@
<template>
<div ref="contentRef" class="markdown-view" v-html="contentHtml"></div>
<!-- <div ref="contentRef" class="markdown-view" v-html="contentHtml"></div>-->
<div ref="contentRef" class="markdown-view" v-html="renderedMarkdown"></div>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { marked } from 'marked'
import {useClipboard} from '@vueuse/core'
import MarkdownIt from 'markdown-it'
import 'highlight.js/styles/vs2015.min.css'
import hljs from 'highlight.js'
@ -19,45 +20,26 @@ const props = defineProps({
const message = useMessage() //
const { copy } = useClipboard() // copy
const contentRef = ref()
const contentHtml = ref<any>() // html
const { content } = toRefs(props) // props
// https://highlightjs.org/
// markdownmarked
/** marked 渲染器 */
const renderer = {
code(code, language, c) {
let highlightHtml
try {
highlightHtml = hljs.highlight(code, { language: language, ignoreIllegals: true }).value
} catch (e) {
// skip
const md = new MarkdownIt({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
const copyHtml = `<div id="copy" data-copy='${str}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${hljs.highlight(lang, str, true).value}</code></pre>`
} catch (__) {}
}
const copyHtml = `<div id="copy" data-copy='${code}' style="position: absolute; right: 10px; top: 5px; color: #fff;cursor: pointer;">复制</div>`
return `<pre style="position: relative;">${copyHtml}<code class="hljs">${highlightHtml}</code></pre>`
return ``
}
}
});
// marked
marked.use({
renderer: renderer
})
/** 监听 content 变化 */
watch(content, async (newValue, oldValue) => {
await renderMarkdown(newValue)
})
/** 渲染 markdown */
const renderMarkdown = async (content: string) => {
contentHtml.value = await marked(content)
}
const renderedMarkdown = computed(() => {
return md.render(props.content);
});
/** 初始化 **/
onMounted(async () => {
// markdown
await renderMarkdown(props.content as string)
// copy
contentRef.value.addEventListener('click', (e: any) => {
if (e.target.id === 'copy') {

View File

@ -0,0 +1,85 @@
<template>
<div class="square-container">
<el-input
v-model="searchText"
style="width: 100%; margin-bottom: 20px"
size="large"
placeholder="请输入要搜索的内容"
:suffix-icon="Search"
@keyup.enter="handleSearch"
/>
<div class="gallery">
<div v-for="item in publicList" :key="item.id" class="gallery-item">
<img :src="item.picUrl" class="img" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ImageApi, ImageVO } from '@/api/ai/image'
import { Search } from '@element-plus/icons-vue'
/** 属性 */
// TODO @fanqueryParams
const pageNo = ref<number>(1)
const pageSize = ref<number>(20)
const publicList = ref<ImageVO[]>([])
const searchText = ref<string>('')
/** 获取数据 */
const getListData = async () => {
const res = await ImageApi.getImagePagePublic({
pageNo: pageNo.value,
pageSize: pageSize.value,
prompt: searchText.value
})
publicList.value = res.list as ImageVO[]
}
/** 搜索 */
const handleSearch = async () => {
await getListData()
}
onMounted(async () => {
await getListData()
})
</script>
<style scoped lang="scss">
.square-container {
background-color: #fff;
padding: 20px;
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
//max-width: 1000px;
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.gallery-item {
position: relative;
overflow: hidden;
background: #f0f0f0;
cursor: pointer;
transition: transform 0.3s;
}
.gallery-item img {
width: 100%;
height: auto;
display: block;
transition: transform 0.3s;
}
.gallery-item:hover img {
transform: scale(1.1);
}
.gallery-item:hover {
transform: scale(1.05);
}
}
</style>

View File

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

View File

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

View File

@ -1,29 +1,29 @@
<template>
<div class="flex flex-col h-full">
<div class="flex flex-col">
<div class="flex-auto flex overflow-hidden">
<el-tabs v-model="currentType" class="flex-auto px-[var(--app-content-padding)]">
<!-- 我的创作 -->
<el-tab-pane label="我的创作" v-loading="loading" name="mine">
<el-tab-pane v-loading="loading" label="我的创作" name="mine">
<el-row v-if="mySongList.length" :gutter="12">
<el-col v-for="song in mySongList" :key="song.id" :span="24">
<songCard v-bind="song"/>
<songCard :songInfo="song" @play="setCurrentSong(song)"/>
</el-col>
</el-row>
<el-empty v-else description="暂无音乐"/>
</el-tab-pane>
<!-- 试听广场 -->
<el-tab-pane label="试听广场" v-loading="loading" name="square">
<el-tab-pane v-loading="loading" label="试听广场" name="square">
<el-row v-if="squareSongList.length" v-loading="loading" :gutter="12">
<el-col v-for="song in squareSongList" :key="song.id" :span="24">
<songCard v-bind="song"/>
<songCard :songInfo="song" @play="setCurrentSong(song)"/>
</el-col>
</el-row>
<el-empty v-else description="暂无音乐"/>
</el-tab-pane>
</el-tabs>
<!-- songInfo -->
<songInfo v-bind="squareSongList[0]" class="flex-none"/>
<songInfo class="flex-none"/>
</div>
<audioBar class="flex-none"/>
</div>
@ -36,13 +36,18 @@ import audioBar from './audioBar/index.vue'
defineOptions({ name: 'Index' })
const currentType = ref('mine')
// loading
const loading = ref(false)
//
const currentSong = ref({})
const mySongList = ref<Recordable[]>([])
const squareSongList = ref<Recordable[]>([])
provide('currentSong', currentSong)
/*
*@Description: 调接口生成音乐列表
*@MethodAuthor: xiaohong
@ -57,7 +62,7 @@ function generateMusic (formData: Recordable) {
id: index,
audioUrl: '',
videoUrl: '',
title: '我走后',
title: '我走后' + index,
imageUrl: 'https://www.carsmp3.com/data/attachment/forum/201909/19/091020q5kgre20fidreqyt.jpg',
desc: 'Metal, symphony, film soundtrack, grand, majesticMetal, dtrack, grand, majestic',
date: '2024年04月30日 14:02:57',
@ -76,6 +81,15 @@ function generateMusic (formData: Recordable) {
}, 3000)
}
/*
*@Description: 设置当前播放的音乐
*@MethodAuthor: xiaohong
*@Date: 2024-07-19 11:22:33
*/
function setCurrentSong (music: Recordable) {
currentSong.value = music
}
defineExpose({
generateMusic
})
@ -86,9 +100,9 @@ defineExpose({
:deep(.el-tabs) {
display: flex;
flex-direction: column;
.el-tabs__content{
padding: 0 7px;
overflow: auto;
}
.el-tabs__content {
padding: 0 7px;
overflow: auto;
}
}
</style>

View File

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

View File

@ -1,11 +1,15 @@
<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-image :src="currentSong.imageUrl"/>
<div class="">{{ currentSong.title }}</div>
<div class="text-[var(--el-text-color-secondary)] text-12px line-clamp-1">
{{ currentSong.desc }}
</div>
<div class="text-[var(--el-text-color-secondary)] text-12px">
{{ currentSong.date }}
</div>
<el-button size="small" round class="my-6px">信息复用</el-button>
<div class="text-[var(--el-text-color-secondary)] text-12px" v-html="lyric"></div>
<div class="text-[var(--el-text-color-secondary)] text-12px" v-html="currentSong.lyric"></div>
</ContentWrap>
</template>
@ -13,21 +17,6 @@
defineOptions({ name: 'Index' })
defineProps({
imageUrl: {
type: String
},
title: {
type: String
},
desc: {
type: String
},
date: {
type: String
},
lyric: {
type: String
}
})
const currentSong = inject('currentSong', {})
</script>

View File

@ -1,5 +1,5 @@
<template>
<ContentWrap class="w-300px h-full">
<ContentWrap class="w-300px h-full mb-[0!important]">
<el-radio-group v-model="generateMode" class="mb-15px">
<el-radio-button label="desc">
描述模式
@ -28,10 +28,7 @@ const emits = defineEmits(['generate-music'])
const generateMode = ref('lyric')
interface ModeRef {
formData: Recordable
}
const modeRef = ref<ModeRef | null>(null)
const modeRef = ref<Nullable<{ formData: Recordable }>>(null)
/*
*@Description: 根据信息生成音乐
@ -39,6 +36,6 @@ const modeRef = ref<ModeRef | null>(null)
*@Date: 2024-06-27 16:40:16
*/
function generateMusic () {
emits('generate-music', {formData: unref(modeRef)?.formData.value})
emits('generate-music', {formData: unref(modeRef)?.formData})
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<el-card class="my-card h-full">
<template #header
><h3 class="m-0 px-7 shrink-0 flex items-center justify-between">
<template #header>
<h3 class="m-0 px-7 shrink-0 flex items-center justify-between">
<span>预览</span>
<!-- 展示在右上角 -->
<el-button color="#846af7" v-show="showCopy" @click="copyContent" size="small">
@ -10,8 +10,8 @@
</template>
复制
</el-button>
</h3></template
>
</h3>
</template>
<div ref="contentRef" class="hide-scroll-bar h-full box-border overflow-y-auto">
<div class="w-full min-h-full relative flex-grow bg-white box-border p-3 sm:p-7">
@ -105,7 +105,7 @@ watch(copied, (val) => {
}
}
.my-card{
.my-card {
display: flex;
flex-direction: column;

View File

@ -1,3 +1,4 @@
<!-- 标签选项 -->
<template>
<div class="flex flex-wrap gap-[8px]">
<span

View File

@ -20,7 +20,7 @@
<script setup lang="ts">
import Left from './components/Left.vue'
import Right from './components/Right.vue'
import { WriteApi } from '@/api/ai/write'
import { WriteApi, WriteVO } from '@/api/ai/write'
import { WriteExample } from '@/views/ai/utils/constants'
const message = useMessage()
@ -37,7 +37,7 @@ const stopStream = () => {
/** 执行写作 */
const rightRef = ref<InstanceType<typeof Right>>()
const submit = (data) => {
const submit = (data: WriteVO) => {
abortController.value = new AbortController()
writeResult.value = ''
isWriting.value = true

View File

@ -21,9 +21,9 @@
</div>
<div class="ml-10px w-100%">
<div class="flex justify-between items-center w-100%">
<span>{{ item.userNickname }}</span>
<span class="username">{{ item.userNickname }}</span>
<span class="color-[#989EA6]">
{{ formatDate(item.lastMessageTime) }}
{{ formatPast(item.lastMessageTime, 'YYYY-mm-dd') }}
</span>
</div>
<!-- 最后聊天内容 -->
@ -70,7 +70,7 @@
<script lang="ts" setup>
import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { useEmoji } from './tools/emoji'
import { formatDate } from '@/utils/formatTime'
import { formatPast } from '@/utils/formatTime'
import { KeFuMessageContentTypeEnum } from './tools/constants'
import { useAppStore } from '@/store/modules/app'
@ -185,6 +185,16 @@ watch(showRightMenu, (val) => {
background-color: #fff;
transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
.username {
min-width: 0;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.last-message {
width: 200px;
overflow: hidden; //

View File

@ -40,19 +40,54 @@
v-if="item.senderType === UserTypeEnum.MEMBER"
:src="conversation.userAvatar"
alt="avatar"
class="w-60px h-60px"
/>
<div
:class="{ 'kefu-message': KeFuMessageContentTypeEnum.TEXT === item.contentType }"
class="p-10px"
>
<!-- 文本消息 -->
<TextMessageItem :message="item" />
<MessageItem :message="item">
<template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
<div
v-dompurify-html="replaceEmoji(item.content)"
class="flex items-center"
></div>
</template>
</MessageItem>
<!-- 图片消息 -->
<ImageMessageItem :message="item" />
<MessageItem :message="item">
<el-image
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
:initial-index="0"
:preview-src-list="[item.content]"
:src="item.content"
class="w-200px"
fit="contain"
preview-teleported
/>
</MessageItem>
<!-- 商品消息 -->
<ProductMessageItem :message="item" />
<MessageItem :message="item">
<ProductItem
v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
:picUrl="getMessageContent(item).picUrl"
:price="getMessageContent(item).price"
:skuText="getMessageContent(item).introduction"
:title="getMessageContent(item).spuName"
:titleWidth="400"
class="max-w-70%"
priceColor="#FF3000"
/>
</MessageItem>
<!-- 订单消息 -->
<OrderMessageItem :message="item" />
<MessageItem :message="item">
<OrderItem
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
:message="item"
class="max-w-70%"
/>
</MessageItem>
</div>
<el-avatar
v-if="item.senderType === UserTypeEnum.ADMIN"
@ -97,24 +132,24 @@ import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/mes
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 ProductMessageItem from './message/ProductMessageItem.vue'
import OrderMessageItem from './message/OrderMessageItem.vue'
import { Emoji } from './tools/emoji'
import ProductItem from './message/ProductItem.vue'
import OrderItem from './message/OrderItem.vue'
import { Emoji, useEmoji } 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'
import { debounce } from 'lodash-es'
import { jsonParse } from '@/utils'
dayjs.extend(relativeTime)
defineOptions({ name: 'KeFuMessageList' })
const message = ref('') //
const { replaceEmoji } = useEmoji()
const messageTool = useMessage()
const messageList = ref<KeFuMessageRespVO[]>([]) //
const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) //
@ -126,18 +161,11 @@ const queryParams = reactive({
})
const total = ref(0) //
const refreshContent = ref(false) // ,
/** 获悉消息内容 */
const getMessageContent = computed(() => (item: any) => jsonParse(item.content))
/** 获得消息列表 */
const getMessageList = async (val: KeFuConversationRespVO, conversationChange: boolean) => {
// ,
if (conversationChange) {
queryParams.pageNo = 1
messageList.value = []
total.value = 0
loadHistory.value = false
refreshContent.value = false
}
conversation.value = val
queryParams.conversationId = val.id
const getMessageList = async () => {
const res = await KeFuMessageApi.getKeFuMessagePage(queryParams)
total.value = res.total
//
@ -146,14 +174,18 @@ const getMessageList = async (val: KeFuConversationRespVO, conversationChange: b
} else {
//
for (const item of res.list) {
if (messageList.value.some((val) => val.id === item.id)) {
continue
}
messageList.value.push(item)
pushMessage(item)
}
}
refreshContent.value = true
await scrollToBottom()
}
/** 添加消息 */
const pushMessage = (message: any) => {
if (messageList.value.some((val) => val.id === message.id)) {
return
}
messageList.value.push(message)
}
/** 按照时间倒序,获取消息列表 */
@ -163,20 +195,49 @@ const getMessageList0 = computed(() => {
})
/** 刷新消息列表 */
const refreshMessageList = async () => {
const refreshMessageList = async (message?: any) => {
if (!conversation.value) {
return
}
queryParams.pageNo = 1
await getMessageList(conversation.value, false)
if (typeof message !== 'undefined') {
//
if (message.conversationId !== conversation.value.id) {
return
}
pushMessage(message)
} else {
// TODO @puhui999 page createTime
queryParams.pageNo = 1
await getMessageList()
}
if (loadHistory.value) {
//
showNewMessageTip.value = true
} else {
//
await handleToNewMessage()
}
}
defineExpose({ getMessageList, refreshMessageList })
/** 获得新会话的消息列表 */
// TODO @puhui999 list merge
const getNewMessageList = async (val: KeFuConversationRespVO) => {
// ,
queryParams.pageNo = 1
messageList.value = []
total.value = 0
loadHistory.value = false
refreshContent.value = false
//
conversation.value = val
queryParams.conversationId = val.id
//
await refreshMessageList()
}
defineExpose({ getNewMessageList, refreshMessageList })
const showKeFuMessageList = computed(() => !isEmpty(conversation.value)) //
const skipGetMessageList = computed(() => {
//
@ -221,9 +282,7 @@ const sendMessage = async (msg: any) => {
await KeFuMessageApi.sendKeFuMessage(msg)
message.value = ''
//
await getMessageList(conversation.value, false)
//
await scrollToBottom()
await refreshMessageList()
}
/** 滚动到底部 */
@ -248,17 +307,24 @@ const handleToNewMessage = async () => {
await scrollToBottom()
}
/** 加载历史消息 */
const loadHistory = ref(false) //
const handleScroll = async ({ scrollTop }) => {
/** 处理消息列表滚动事件(debounce 限流) */
const handleScroll = debounce(({ scrollTop }) => {
if (skipGetMessageList.value) {
return
}
//
if (scrollTop === 0) {
await handleOldMessage()
if (Math.floor(scrollTop) === 0) {
handleOldMessage()
}
}
const wrap = scrollbarRef.value?.wrapRef
//
if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
loadHistory.value = false
refreshMessageList()
}
}, 200)
/** 加载历史消息 */
const handleOldMessage = async () => {
//
const oldPageHeight = innerRef.value?.clientHeight
@ -268,7 +334,7 @@ const handleOldMessage = async () => {
loadHistory.value = true
//
queryParams.pageNo += 1
await getMessageList(conversation.value, false)
await getMessageList()
//
scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
}

View File

@ -0,0 +1,97 @@
<!-- 目录是不是叫 member 好点然后这个组件是 MemberInfo里面有浏览足迹 -->
<template>
<div v-show="!isEmpty(conversation)" class="kefu">
<div class="header-title h-60px flex justify-center items-center">他的足迹</div>
<el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick">
<el-tab-pane label="最近浏览" name="a" />
<el-tab-pane label="订单列表" name="b" />
</el-tabs>
<div>
<el-scrollbar ref="scrollbarRef" always height="calc(100vh - 400px)" @scroll="handleScroll">
<!-- 最近浏览 -->
<ProductBrowsingHistory v-if="activeName === 'a'" ref="productBrowsingHistoryRef" />
<!-- 订单列表 -->
<OrderBrowsingHistory v-if="activeName === 'b'" ref="orderBrowsingHistoryRef" />
</el-scrollbar>
</div>
</div>
<el-empty v-show="isEmpty(conversation)" description="请选择左侧的一个会话后开始" />
</template>
<script lang="ts" setup>
import type { TabsPaneContext } from 'element-plus'
import ProductBrowsingHistory from './ProductBrowsingHistory.vue'
import OrderBrowsingHistory from './OrderBrowsingHistory.vue'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { isEmpty } from '@/utils/is'
import { debounce } from 'lodash-es'
import { ElScrollbar as ElScrollbarType } from 'element-plus/es/components/scrollbar'
defineOptions({ name: 'MemberBrowsingHistory' })
const activeName = ref('a')
/** tab 切换 */
const productBrowsingHistoryRef = ref<InstanceType<typeof ProductBrowsingHistory>>()
const orderBrowsingHistoryRef = ref<InstanceType<typeof OrderBrowsingHistory>>()
const handleClick = async (tab: TabsPaneContext) => {
activeName.value = tab.paneName as string
await nextTick()
await getHistoryList()
}
/** 获得历史数据 */
// TODO @puhui ab
const getHistoryList = async () => {
switch (activeName.value) {
case 'a':
await productBrowsingHistoryRef.value?.getHistoryList(conversation.value)
break
case 'b':
await orderBrowsingHistoryRef.value?.getHistoryList(conversation.value)
break
default:
break
}
}
/** 加载下一页数据 */
const loadMore = async () => {
switch (activeName.value) {
case 'a':
await productBrowsingHistoryRef.value?.loadMore()
break
case 'b':
await orderBrowsingHistoryRef.value?.loadMore()
break
default:
break
}
}
/** 浏览历史初始化 */
const conversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) //
const initHistory = async (val: KeFuConversationRespVO) => {
activeName.value = 'a'
conversation.value = val
await nextTick()
await getHistoryList()
}
defineExpose({ initHistory })
/** 处理消息列表滚动事件(debounce 限流) */
const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
const handleScroll = debounce(() => {
const wrap = scrollbarRef.value?.wrapRef
//
if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
loadMore()
}
}, 200)
</script>
<style lang="scss" scoped>
.header-title {
border-bottom: #e4e0e0 solid 1px;
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<OrderItem v-for="item in list" :key="item.id" :order="item" class="mb-10px" />
</template>
<script lang="ts" setup>
import OrderItem from '@/views/mall/promotion/kefu/components/message/OrderItem.vue'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { getOrderPage } from '@/api/mall/trade/order'
import { concat } from 'lodash-es'
defineOptions({ name: 'OrderBrowsingHistory' })
const list = ref<any>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: 0
})
const skipGetMessageList = computed(() => {
//
return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
}) //
/** 获得浏览记录 */
const getHistoryList = async (val: KeFuConversationRespVO) => {
queryParams.userId = val.userId
const res = await getOrderPage(queryParams)
total.value = res.total
list.value = res.list
}
/** 加载下一页数据 */
const loadMore = async () => {
if (skipGetMessageList.value) {
return
}
queryParams.pageNo += 1
const res = await getOrderPage(queryParams)
total.value = res.total
concat(list.value, res.list)
}
defineExpose({ getHistoryList, loadMore })
</script>

View File

@ -0,0 +1,57 @@
<template>
<ProductItem
v-for="item in list"
:key="item.id"
:picUrl="item.picUrl"
:price="item.price"
:skuText="item.introduction"
:title="item.spuName"
:titleWidth="400"
class="mb-10px"
priceColor="#FF3000"
/>
</template>
<script lang="ts" setup>
import { getBrowseHistoryPage } from '@/api/mall/product/history'
import ProductItem from '@/views/mall/promotion/kefu/components/message/ProductItem.vue'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { concat } from 'lodash-es'
defineOptions({ name: 'ProductBrowsingHistory' })
const list = ref<any>([]) //
const total = ref(0) //
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: 0,
userDeleted: false
})
const skipGetMessageList = computed(() => {
//
return total.value > 0 && Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
}) //
/** 获得浏览记录 */
const getHistoryList = async (val: KeFuConversationRespVO) => {
queryParams.userId = val.userId
const res = await getBrowseHistoryPage(queryParams)
total.value = res.total
list.value = res.list
}
/** 加载下一页数据 */
const loadMore = async () => {
if (skipGetMessageList.value) {
return
}
queryParams.pageNo += 1
const res = await getBrowseHistoryPage(queryParams)
total.value = res.total
concat(list.value, res.list)
}
defineExpose({ getHistoryList, loadMore })
</script>
<style lang="scss" scoped></style>

View File

@ -1,4 +1,5 @@
import KeFuConversationList from './KeFuConversationList.vue'
import KeFuMessageList from './KeFuMessageList.vue'
import MemberBrowsingHistory from './history/MemberBrowsingHistory.vue'
export { KeFuConversationList, KeFuMessageList }
export { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory }

View File

@ -1,34 +0,0 @@
<template>
<!-- 图片消息 -->
<template v-if="KeFuMessageContentTypeEnum.IMAGE === message.contentType">
<div
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: message.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
>
<el-image
:initial-index="0"
:preview-src-list="[message.content]"
:src="message.content"
class="w-200px"
fit="contain"
preview-teleported
/>
</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'
defineOptions({ name: 'ImageMessageItem' })
defineProps<{
message: KeFuMessageRespVO
}>()
</script>

View File

@ -0,0 +1,24 @@
<template>
<!-- 消息组件 -->
<div
:class="[
message.senderType === UserTypeEnum.MEMBER
? `ml-10px`
: message.senderType === UserTypeEnum.ADMIN
? `mr-10px`
: ''
]"
>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { UserTypeEnum } from '@/utils/constants'
import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
defineOptions({ name: 'MessageItem' })
defineProps<{
message: KeFuMessageRespVO
}>()
</script>

View File

@ -0,0 +1,146 @@
<template>
<div v-if="isObject(getMessageContent)">
<div :key="getMessageContent.id" class="order-list-card-box mt-14px">
<div class="order-card-header flex items-center justify-between p-x-20px">
<div class="order-no">订单号{{ getMessageContent.no }}</div>
<div :class="formatOrderColor(getMessageContent)" class="order-state font-16">
{{ formatOrderStatus(getMessageContent) }}
</div>
</div>
<div v-for="item in getMessageContent.items" :key="item.id" class="border-bottom">
<ProductItem
:num="item.count"
:picUrl="item.picUrl"
:price="item.price"
:skuText="item.properties.map((property: any) => property.valueName).join(' ')"
:title="item.spuName"
/>
</div>
<div class="pay-box flex justify-end pr-20px">
<div class="flex items-center">
<div class="discounts-title pay-color"
> {{ getMessageContent?.productCount }} 件商品,总金额:
</div>
<div class="discounts-money pay-color">
{{ fenToYuan(getMessageContent?.payPrice) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { fenToYuan, jsonParse } from '@/utils'
import { KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
import { isObject } from '@/utils/is'
import ProductItem from '@/views/mall/promotion/kefu/components/message/ProductItem.vue'
defineOptions({ name: 'OrderItem' })
const props = defineProps<{
message?: KeFuMessageRespVO
order?: any
}>()
const getMessageContent = computed(() =>
typeof props.message !== 'undefined' ? jsonParse(props!.message!.content) : props.order
)
/**
* 格式化订单状态的颜色
*
* @param order 订单
* @return {string} 颜色的 class 名称
*/
function formatOrderColor(order: any) {
if (order.status === 0) {
return 'info-color'
}
if (order.status === 10 || order.status === 20 || (order.status === 30 && !order.commentStatus)) {
return 'warning-color'
}
if (order.status === 30 && order.commentStatus) {
return 'success-color'
}
return 'danger-color'
}
/**
* 格式化订单状态
*
* @param order 订单
*/
function formatOrderStatus(order: any) {
if (order.status === 0) {
return '待付款'
}
if (order.status === 10 && order.deliveryType === 1) {
return '待发货'
}
if (order.status === 10 && order.deliveryType === 2) {
return '待核销'
}
if (order.status === 20) {
return '待收货'
}
if (order.status === 30 && !order.commentStatus) {
return '待评价'
}
if (order.status === 30 && order.commentStatus) {
return '已完成'
}
return '已关闭'
}
</script>
<style lang="scss" scoped>
.order-list-card-box {
border-radius: 10px;
padding: 10px;
background-color: #e2e2e2;
.order-card-header {
height: 28px;
.order-no {
font-size: 16px;
font-weight: 500;
}
}
.pay-box {
.discounts-title {
font-size: 16px;
line-height: normal;
color: #999999;
}
.discounts-money {
font-size: 16px;
line-height: normal;
color: #999;
font-family: OPPOSANS;
}
.pay-color {
color: #333;
}
}
}
.warning-color {
color: #faad14;
}
.danger-color {
color: #ff3000;
}
.success-color {
color: #52c41a;
}
.info-color {
color: #999999;
}
</style>

View File

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

View File

@ -110,33 +110,25 @@ const skuString = computed(() => {
</script>
<style lang="scss" scoped>
.score-img {
width: 36px;
height: 36px;
margin: 0 4px;
}
.ss-order-card-warp {
padding: 20px;
border-radius: 10px;
background-color: #e2e2e2;
.img-box {
width: 164px;
height: 164px;
width: 80px;
height: 80px;
border-radius: 10px;
overflow: hidden;
.order-img {
width: 164px;
height: 164px;
width: 80px;
height: 80px;
}
}
.box-right {
flex: 1;
// width: 500px;
// height: 164px;
position: relative;
.tool-box {
@ -147,13 +139,13 @@ const skuString = computed(() => {
}
.title-text {
font-size: 28px;
font-size: 16px;
font-weight: 500;
line-height: 40px;
line-height: 20px;
}
.spec-text {
font-size: 24px;
font-size: 16px;
font-weight: 400;
color: #999999;
min-width: 0;
@ -165,15 +157,15 @@ const skuString = computed(() => {
}
.price-text {
font-size: 24px;
font-size: 16px;
font-weight: 500;
font-family: OPPOSANS;
}
.total-text {
font-size: 24px;
font-size: 16px;
font-weight: 400;
line-height: 24px;
line-height: 16px;
color: #999999;
margin-left: 8px;
}

View File

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

View File

@ -1,29 +0,0 @@
<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

@ -1,22 +1,28 @@
<template>
<el-row :gutter="10">
<!-- 会话列表 -->
<el-col :span="8">
<el-col :span="6">
<ContentWrap>
<KeFuConversationList ref="keFuConversationRef" @change="handleChange" />
</ContentWrap>
</el-col>
<!-- 会话详情选中会话的消息列表 -->
<el-col :span="16">
<el-col :span="12">
<ContentWrap>
<KeFuMessageList ref="keFuChatBoxRef" @change="getConversationList" />
</ContentWrap>
</el-col>
<!-- 会员足迹选中会话的会员足迹 -->
<el-col :span="6">
<ContentWrap>
<MemberBrowsingHistory ref="memberBrowsingHistoryRef" />
</ContentWrap>
</el-col>
</el-row>
</template>
<script lang="ts" setup>
import { KeFuConversationList, KeFuMessageList } from './components'
import { KeFuConversationList, KeFuMessageList, MemberBrowsingHistory } from './components'
import { WebSocketMessageTypeConstants } from './components/tools/constants'
import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
import { getAccessToken } from '@/utils/auth'
@ -58,14 +64,16 @@ watchEffect(() => {
// 2.2 KEFU_MESSAGE_TYPE
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
//
// TODO @puhui999 update
getConversationList()
//
keFuChatBoxRef.value?.refreshMessageList()
keFuChatBoxRef.value?.refreshMessageList(JSON.parse(jsonMessage.content))
return
}
// 2.3 KEFU_MESSAGE_ADMIN_READ
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) {
//
// TODO @puhui999 update
getConversationList()
}
} catch (error) {
@ -81,8 +89,10 @@ const getConversationList = () => {
/** 加载指定会话的消息列表 */
const keFuChatBoxRef = ref<InstanceType<typeof KeFuMessageList>>()
const memberBrowsingHistoryRef = ref<InstanceType<typeof MemberBrowsingHistory>>()
const handleChange = (conversation: KeFuConversationRespVO) => {
keFuChatBoxRef.value?.getMessageList(conversation, true)
keFuChatBoxRef.value?.getNewMessageList(conversation)
memberBrowsingHistoryRef.value?.initHistory(conversation)
}
/** 初始化 */