Pārlūkot izejas kodu

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

# Conflicts:
#	package.json
hhhero 9 mēneši atpakaļ
vecāks
revīzija
a4a376b18d
32 mainītis faili ar 769 papildinājumiem un 452 dzēšanām
  1. BIN
      .image/common/ai-feature.png
  2. BIN
      .image/common/ai-preview.gif
  3. 6 8
      README.md
  4. 4 0
      src/api/ai/image/index.ts
  5. 1 1
      src/api/ai/write/index.ts
  6. 10 0
      src/api/mall/product/history.ts
  7. BIN
      src/assets/audio/response.mp3
  8. 16 34
      src/components/MarkdownView/index.vue
  9. 85 0
      src/views/ai/image/square/index.vue
  10. 7 2
      src/views/ai/music/components/index.vue
  11. 62 1
      src/views/ai/music/components/list/audioBar/index.vue
  12. 25 11
      src/views/ai/music/components/list/index.vue
  13. 18 11
      src/views/ai/music/components/list/songCard/index.vue
  14. 11 22
      src/views/ai/music/components/list/songInfo/index.vue
  15. 3 6
      src/views/ai/music/components/mode/index.vue
  16. 5 5
      src/views/ai/write/index/components/Right.vue
  17. 1 0
      src/views/ai/write/index/components/Tag.vue
  18. 2 2
      src/views/ai/write/index/index.vue
  19. 13 3
      src/views/mall/promotion/kefu/components/KeFuConversationList.vue
  20. 105 39
      src/views/mall/promotion/kefu/components/KeFuMessageList.vue
  21. 97 0
      src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue
  22. 44 0
      src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue
  23. 57 0
      src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue
  24. 2 1
      src/views/mall/promotion/kefu/components/index.ts
  25. 0 34
      src/views/mall/promotion/kefu/components/message/ImageMessageItem.vue
  26. 24 0
      src/views/mall/promotion/kefu/components/message/MessageItem.vue
  27. 146 0
      src/views/mall/promotion/kefu/components/message/OrderItem.vue
  28. 0 182
      src/views/mall/promotion/kefu/components/message/OrderMessageItem.vue
  29. 10 18
      src/views/mall/promotion/kefu/components/message/ProductItem.vue
  30. 0 38
      src/views/mall/promotion/kefu/components/message/ProductMessageItem.vue
  31. 0 29
      src/views/mall/promotion/kefu/components/message/TextMessageItem.vue
  32. 15 5
      src/views/mall/promotion/kefu/index.vue

BIN
.image/common/ai-feature.png


BIN
.image/common/ai-preview.gif


+ 6 - 8
README.md

@@ -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/>
 
-### CRM 系统
+![功能图](/.image/common/erp-feature.png)
 
-![功能图](/.image/common/crm-feature.png)
+### CRM 系统
 
 演示地址:<https://doc.iocoder.cn/crm-preview/>
 
+![功能图](/.image/common/crm-feature.png)
+
 ## 🐷 演示图
 
 ### 系统功能

+ 4 - 0
src/api/ai/image/index.ts

@@ -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}` })

+ 1 - 1
src/api/ai/write/index.ts

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

+ 10 - 0
src/api/mall/product/history.ts

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

BIN
src/assets/audio/response.mp3


+ 16 - 34
src/components/MarkdownView/index.vue

@@ -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/
-// 转换 markdown:marked
-
-/** 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') {

+ 85 - 0
src/views/ai/image/square/index.vue

@@ -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 @fan:queryParams 里面搞分页哈。
+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>

+ 7 - 2
src/views/ai/music/components/index.vue

@@ -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)
 }

+ 62 - 1
src/views/ai/music/components/list/audioBar/index.vue

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

+ 25 - 11
src/views/ai/music/components/list/index.vue

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

+ 18 - 11
src/views/ai/music/components/list/songCard/index.vue

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

+ 11 - 22
src/views/ai/music/components/list/songInfo/index.vue

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

+ 3 - 6
src/views/ai/music/components/mode/index.vue

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

+ 5 - 5
src/views/ai/write/index/components/Right.vue

@@ -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;
 

+ 1 - 0
src/views/ai/write/index/components/Tag.vue

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

+ 2 - 2
src/views/ai/write/index/index.vue

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

+ 13 - 3
src/views/mall/promotion/kefu/components/KeFuConversationList.vue

@@ -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; // 隐藏超出的文本

+ 105 - 39
src/views/mall/promotion/kefu/components/KeFuMessageList.vue

@@ -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)
 }

+ 97 - 0
src/views/mall/promotion/kefu/components/history/MemberBrowsingHistory.vue

@@ -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:不要用 a、b 哈。就订单列表、浏览列表这种噶
+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>

+ 44 - 0
src/views/mall/promotion/kefu/components/history/OrderBrowsingHistory.vue

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

+ 57 - 0
src/views/mall/promotion/kefu/components/history/ProductBrowsingHistory.vue

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

+ 2 - 1
src/views/mall/promotion/kefu/components/index.ts

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

+ 0 - 34
src/views/mall/promotion/kefu/components/message/ImageMessageItem.vue

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

+ 24 - 0
src/views/mall/promotion/kefu/components/message/MessageItem.vue

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

+ 146 - 0
src/views/mall/promotion/kefu/components/message/OrderItem.vue

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

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

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

+ 10 - 18
src/views/mall/promotion/kefu/components/message/ProductItem.vue

@@ -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;
   }

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

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

+ 0 - 29
src/views/mall/promotion/kefu/components/message/TextMessageItem.vue

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

+ 15 - 5
src/views/mall/promotion/kefu/index.vue

@@ -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)
 }
 
 /** 初始化 */