소스 검색

!468 【新增】:mall 客服
Merge pull request !468 from puhui999/dev-crm

芋道源码 11 달 전
부모
커밋
8d759b2a0b

+ 3 - 0
.env.local

@@ -29,5 +29,8 @@ VITE_BASE_PATH=/
 # 商城H5会员端域名
 VITE_MALL_H5_DOMAIN='http://localhost:3000'
 
+# 客户端静态资源地址 空=默认使用服务端指定的CDN资源地址前缀 | local=本地  |  http(s)://xxx.xxx=自定义静态资源地址前缀
+VITE_STATIC_URL = https://file.sheepjs.com
+
 # 验证码的开关
 VITE_APP_CAPTCHA_ENABLE=false

+ 71 - 0
src/api/mall/promotion/kefu/conversation/index.ts

@@ -0,0 +1,71 @@
+import request from '@/config/axios'
+
+export interface KeFuConversationRespVO {
+  /**
+   * 编号
+   */
+  id: number
+  /**
+   * 会话所属用户
+   */
+  userId: number
+  /**
+   * 会话所属用户头像
+   */
+  userAvatar: string
+  /**
+   * 会话所属用户昵称
+   */
+  userNickname: string
+  /**
+   * 最后聊天时间
+   */
+  lastMessageTime: Date
+  /**
+   * 最后聊天内容
+   */
+  lastMessageContent: string
+  /**
+   * 最后发送的消息类型
+   */
+  lastMessageContentType: number
+  /**
+   * 管理端置顶
+   */
+  adminPinned: boolean
+  /**
+   * 用户是否可见
+   */
+  userDeleted: boolean
+  /**
+   * 管理员是否可见
+   */
+  adminDeleted: boolean
+  /**
+   * 管理员未读消息数
+   */
+  adminUnreadMessageCount: number
+  /**
+   * 创建时间
+   */
+  createTime?: string
+}
+
+// 客服会话 API
+export const KeFuConversationApi = {
+  // 获得客服会话列表
+  getConversationList: async () => {
+    return await request.get({ url: '/promotion/kefu-conversation/list' })
+  },
+  // 客服会话置顶
+  updateConversationPinned: async (data: any) => {
+    return await request.put({
+      url: '/promotion/kefu-conversation/update-conversation-pinned',
+      data
+    })
+  },
+  // 删除客服会话
+  deleteConversation: async (id: number) => {
+    return await request.get({ url: '/promotion/kefu-conversation/delete?id' + id })
+  }
+}

+ 70 - 0
src/api/mall/promotion/kefu/message/index.ts

@@ -0,0 +1,70 @@
+import request from '@/config/axios'
+
+export interface KeFuMessageRespVO {
+  /**
+   * 编号
+   */
+  id: number
+  /**
+   * 会话编号
+   */
+  conversationId: number
+  /**
+   * 发送人编号
+   */
+  senderId: number
+  /**
+   * 发送人头像
+   */
+  senderAvatar: string
+  /**
+   * 发送人类型
+   */
+  senderType: number
+  /**
+   * 接收人编号
+   */
+  receiverId: number
+  /**
+   * 接收人类型
+   */
+  receiverType: number
+  /**
+   * 消息类型
+   */
+  contentType: number
+  /**
+   * 消息
+   */
+  content: string
+  /**
+   * 是否已读
+   */
+  readStatus: boolean
+  /**
+   * 创建时间
+   */
+  createTime: Date
+}
+
+// 客服会话 API
+export const KeFuMessageApi = {
+  // 发送客服消息
+  sendKeFuMessage: async (data: any) => {
+    return await request.post({
+      url: '/promotion/kefu-message/send',
+      data
+    })
+  },
+  // 更新客服消息已读状态
+  updateKeFuMessageReadStatus: async (data: any) => {
+    return await request.put({
+      url: '/promotion/kefu-message/update-read-status',
+      data
+    })
+  },
+  // 获得消息分页数据
+  getKeFuMessagePage: async (params: any) => {
+    return await request.get({ url: '/promotion/kefu-message/page', params })
+  }
+}

+ 2 - 2
src/views/infra/webSocket/index.vue

@@ -29,8 +29,8 @@
         :autosize="{ minRows: 2, maxRows: 4 }"
         :disabled="!getIsOpen"
         clearable
-        type="textarea"
         placeholder="请输入你要发送的消息"
+        type="textarea"
       />
       <el-select v-model="sendUserId" class="mt-4" placeholder="请选择发送人">
         <el-option key="" label="所有人" value="" />
@@ -71,7 +71,7 @@
 <script lang="ts" setup>
 import { formatDate } from '@/utils/formatTime'
 import { useWebSocket } from '@vueuse/core'
-import { getAccessToken } from '@/utils/auth'
+// import { getAccessToken } from '@/utils/auth'
 import * as UserApi from '@/api/system/user'
 
 defineOptions({ name: 'InfraWebSocket' })

+ 40 - 0
src/views/mall/promotion/kefu/components/EmojiSelectPopover.vue

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

+ 258 - 0
src/views/mall/promotion/kefu/components/KeFuChatBox.vue

@@ -0,0 +1,258 @@
+<template>
+  <el-container v-if="showChatBox" class="kefu">
+    <el-header>
+      <div class="kefu-title">{{ keFuConversation.userNickname }}</div>
+    </el-header>
+    <el-main class="kefu-content" style="overflow: visible">
+      <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)">
+        <div ref="innerRef" class="w-[100%] pb-3px">
+          <div
+            v-for="item in messageList"
+            :key="item.id"
+            :class="[
+              item.senderType === UserTypeEnum.MEMBER
+                ? `ss-row-left`
+                : item.senderType === UserTypeEnum.ADMIN
+                  ? `ss-row-right`
+                  : ''
+            ]"
+            class="flex mb-20px w-[100%]"
+          >
+            <el-avatar
+              v-show="item.senderType === UserTypeEnum.MEMBER"
+              :src="keFuConversation.userAvatar"
+              alt="avatar"
+            />
+            <div class="kefu-message p-10px">
+              <!-- TODO puhui999: 消息相关等后续完成后统一抽离封装 -->
+              <!-- 文本消息 -->
+              <template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
+                <div
+                  v-dompurify-html="replaceEmoji(item.content)"
+                  :class="[
+                    item.senderType === UserTypeEnum.MEMBER
+                      ? `ml-10px`
+                      : item.senderType === UserTypeEnum.ADMIN
+                        ? `mr-10px`
+                        : ''
+                  ]"
+                  class="flex items-center"
+                ></div>
+              </template>
+              <!-- 图片消息 -->
+              <template v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType">
+                <div
+                  :class="[
+                    item.senderType === UserTypeEnum.MEMBER
+                      ? `ml-10px`
+                      : item.senderType === UserTypeEnum.ADMIN
+                        ? `mr-10px`
+                        : ''
+                  ]"
+                  class="flex items-center"
+                >
+                  <el-image
+                    :src="item.content"
+                    fit="contain"
+                    style="width: 200px; height: 200px"
+                    @click="imagePreview(item.content)"
+                  />
+                </div>
+              </template>
+            </div>
+            <el-avatar
+              v-show="item.senderType === UserTypeEnum.ADMIN"
+              :src="item.senderAvatar"
+              alt="avatar"
+            />
+          </div>
+        </div>
+      </el-scrollbar>
+    </el-main>
+    <el-footer height="230px">
+      <div class="h-[100%]">
+        <div class="chat-tools">
+          <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
+        </div>
+        <el-input v-model="message" :rows="6" type="textarea" />
+        <div class="h-45px flex justify-end">
+          <el-button class="mt-10px" type="primary" @click="handleSendMessage">发送</el-button>
+        </div>
+      </div>
+    </el-footer>
+  </el-container>
+  <el-empty v-else description="请选择左侧的一个会话后开始" />
+</template>
+
+<script lang="ts" setup>
+import { ElScrollbar as ElScrollbarType } from 'element-plus'
+import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
+import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import EmojiSelectPopover from './EmojiSelectPopover.vue'
+import { Emoji, replaceEmoji } from './emoji'
+import { KeFuMessageContentTypeEnum } from './constants'
+import { isEmpty } from '@/utils/is'
+import { UserTypeEnum } from '@/utils/constants'
+import { createImageViewer } from '@/components/ImageViewer'
+
+defineOptions({ name: 'KeFuMessageBox' })
+const messageTool = useMessage()
+const message = ref('') // 消息
+const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
+const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
+const poller = ref<any>(null) // TODO puhui999: 轮训定时器,暂时模拟 websocket
+// 获得消息 TODO puhui999:  先不考虑下拉加载历史消息
+const getMessageList = async (conversation: KeFuConversationRespVO) => {
+  keFuConversation.value = conversation
+  const { list } = await KeFuMessageApi.getKeFuMessagePage({
+    pageNo: 1,
+    conversationId: conversation.id
+  })
+  messageList.value = list.reverse()
+  // TODO puhui999: 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
+  await scrollToBottom()
+  // TODO puhui999: 轮训相关,功能完善后移除
+  if (!poller.value) {
+    poller.value = setInterval(() => {
+      getMessageList(conversation)
+    }, 1000)
+  }
+}
+defineExpose({ getMessageList })
+// 是否显示聊天区域
+const showChatBox = computed(() => !isEmpty(keFuConversation.value))
+// 处理表情选择
+const handleEmojiSelect = (item: Emoji) => {
+  message.value += item.name
+}
+// 发送消息
+const handleSendMessage = async () => {
+  // 1. 校验消息是否为空
+  if (isEmpty(unref(message.value))) {
+    messageTool.warning('请输入消息后再发送哦!')
+  }
+  // 2. 组织发送消息
+  const msg = {
+    conversationId: keFuConversation.value.id,
+    contentType: KeFuMessageContentTypeEnum.TEXT,
+    content: message.value
+  }
+  await KeFuMessageApi.sendKeFuMessage(msg)
+  message.value = ''
+  // 3. 加载消息列表
+  await getMessageList(keFuConversation.value)
+  // 滚动到最新消息处
+  await scrollToBottom()
+}
+
+const innerRef = ref<HTMLDivElement>()
+const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
+// 滚动到底部
+const scrollToBottom = async () => {
+  await nextTick()
+  scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
+}
+
+/** 图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
+
+// TODO puhui999: 轮训相关,功能完善后移除
+onBeforeUnmount(() => {
+  if (!poller.value) {
+    return
+  }
+  clearInterval(poller.value)
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  &-title {
+    border-bottom: #e4e0e0 solid 1px;
+    height: 60px;
+    line-height: 60px;
+  }
+
+  &-content {
+    .ss-row-left {
+      justify-content: flex-start;
+
+      .kefu-message {
+        margin-left: 20px;
+        position: relative;
+
+        &::before {
+          content: '';
+          width: 10px;
+          height: 10px;
+          left: -19px;
+          top: calc(50% - 10px);
+          position: absolute;
+          border-left: 5px solid transparent;
+          border-bottom: 5px solid transparent;
+          border-top: 5px solid transparent;
+          border-right: 5px solid #ffffff;
+        }
+      }
+    }
+
+    .ss-row-right {
+      justify-content: flex-end;
+
+      .kefu-message {
+        margin-right: 20px;
+        position: relative;
+
+        &::after {
+          content: '';
+          width: 10px;
+          height: 10px;
+          right: -19px;
+          top: calc(50% - 10px);
+          position: absolute;
+          border-left: 5px solid #ffffff;
+          border-bottom: 5px solid transparent;
+          border-top: 5px solid transparent;
+          border-right: 5px solid transparent;
+        }
+      }
+    }
+
+    // 消息气泡
+    .kefu-message {
+      color: #333;
+      border-radius: 5px;
+      box-shadow: 3px 5px 15px rgba(0, 0, 0, 0.2);
+      padding: 5px 10px;
+      width: auto;
+      max-width: 50%;
+      text-align: left;
+      display: inline-block !important;
+      position: relative;
+      word-break: break-all;
+      background-color: #ffffff;
+      transition: all 0.2s;
+
+      &:hover {
+        transform: scale(1.03);
+      }
+    }
+  }
+
+  .chat-tools {
+    width: 100%;
+    border: #e4e0e0 solid 1px;
+    height: 44px;
+    display: flex;
+    align-items: center;
+  }
+
+  ::v-deep(textarea) {
+    resize: none;
+  }
+}
+</style>

+ 114 - 0
src/views/mall/promotion/kefu/components/KeFuConversationBox.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="kefu">
+    <div
+      v-for="(item, index) in conversationList"
+      :key="item.id"
+      :class="{ active: index === activeConversationIndex }"
+      class="kefu-conversation flex justify-between items-center"
+      @click="openRightMessage(item, index)"
+    >
+      <div class="flex justify-center items-center w-100%">
+        <el-avatar :src="item.userAvatar" alt="avatar" />
+        <div class="ml-10px w-100%">
+          <div class="flex justify-between items-center w-100%">
+            <span>{{ item.userNickname }}</span>
+            <span class="color-[#989EA6]">
+              {{ formatDate(item.lastMessageTime) }}
+            </span>
+          </div>
+          <!-- 文本消息 -->
+          <template v-if="KeFuMessageContentTypeEnum.TEXT === item.lastMessageContentType">
+            <div
+              v-dompurify-html="replaceEmoji(item.lastMessageContent)"
+              class="last-message flex items-center color-[#989EA6]"
+            ></div>
+          </template>
+          <!-- 图片消息 -->
+          <template v-if="KeFuMessageContentTypeEnum.IMAGE === item.lastMessageContentType">
+            <div class="last-message flex items-center color-[#989EA6]">【图片消息】</div>
+          </template>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { KeFuConversationApi, KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+import { replaceEmoji } from '@/views/mall/promotion/kefu/components/emoji'
+import { formatDate, getNowDateTime } from '@/utils/formatTime'
+import { KeFuMessageContentTypeEnum } from '@/views/mall/promotion/kefu/components/constants'
+
+defineOptions({ name: 'KeFuConversationBox' })
+const activeConversationIndex = ref(-1) // 选中的会话
+const conversationList = ref<KeFuConversationRespVO[]>([]) // 会话列表
+const getConversationList = async () => {
+  conversationList.value = await KeFuConversationApi.getConversationList()
+  // 测试数据
+  for (let i = 0; i < 5; i++) {
+    conversationList.value.push({
+      id: 1,
+      userId: 283,
+      userAvatar:
+        'https://thirdwx.qlogo.cn/mmopen/vi_32/Q0j4TwGTfTKMezSxtOImrC9lbhwHiazYwck3xwrEcO7VJfG6WQo260whaeVNoByE5RreiaGsGfOMlIiaDhSaA991w/132',
+      userNickname: '辉辉鸭' + i,
+      lastMessageTime: getNowDateTime(),
+      lastMessageContent:
+        '[爱心][爱心]你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇你好哇',
+      lastMessageContentType: 1,
+      adminPinned: false,
+      userDeleted: false,
+      adminDeleted: false,
+      adminUnreadMessageCount: 19
+    })
+  }
+}
+defineExpose({ getConversationList })
+const emits = defineEmits<{
+  (e: 'change', v: KeFuConversationRespVO): void
+}>()
+// 打开右侧消息
+const openRightMessage = (item: KeFuConversationRespVO, index: number) => {
+  activeConversationIndex.value = index
+  emits('change', item)
+}
+const poller = ref<any>(null) // TODO puhui999: 轮训定时器,暂时模拟 websocket
+onMounted(() => {
+  // TODO puhui999: 轮训相关,功能完善后移除
+  if (!poller.value) {
+    poller.value = setInterval(() => {
+      getConversationList()
+    }, 1000)
+  }
+})
+// TODO puhui999: 轮训相关,功能完善后移除
+onBeforeUnmount(() => {
+  if (!poller.value) {
+    return
+  }
+  clearInterval(poller.value)
+})
+</script>
+
+<style lang="scss" scoped>
+.kefu {
+  &-conversation {
+    height: 60px;
+    padding: 10px;
+    background-color: #fff;
+    transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
+
+    .last-message {
+      width: 200px;
+      overflow: hidden; // 隐藏超出的文本
+      white-space: nowrap; // 禁止换行
+      text-overflow: ellipsis; // 添加省略号
+    }
+  }
+
+  .active {
+    border-left: 5px #3271ff solid;
+    background-color: #eff0f1;
+  }
+}
+</style>

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

@@ -0,0 +1,10 @@
+export const KeFuMessageContentTypeEnum = {
+  TEXT: 1, // 文本消息
+  IMAGE: 2, // 图片消息
+  VOICE: 3, // 语音消息
+  VIDEO: 4, // 视频消息
+  SYSTEM: 5, // 系统消息
+  // ========== 商城特殊消息 ==========
+  PRODUCT: 10, //  商品消息
+  ORDER: 11 //  订单消息"
+}

+ 106 - 0
src/views/mall/promotion/kefu/components/emoji.ts

@@ -0,0 +1,106 @@
+export const emojiList = [
+  { name: '[笑掉牙]', file: 'xiaodiaoya.png' },
+  { name: '[可爱]', file: 'keai.png' },
+  { name: '[冷酷]', file: 'lengku.png' },
+  { name: '[闭嘴]', file: 'bizui.png' },
+  { name: '[生气]', file: 'shengqi.png' },
+  { name: '[惊恐]', file: 'jingkong.png' },
+  { name: '[瞌睡]', file: 'keshui.png' },
+  { name: '[大笑]', file: 'daxiao.png' },
+  { name: '[爱心]', file: 'aixin.png' },
+  { name: '[坏笑]', file: 'huaixiao.png' },
+  { name: '[飞吻]', file: 'feiwen.png' },
+  { name: '[疑问]', file: 'yiwen.png' },
+  { name: '[开心]', file: 'kaixin.png' },
+  { name: '[发呆]', file: 'fadai.png' },
+  { name: '[流泪]', file: 'liulei.png' },
+  { name: '[汗颜]', file: 'hanyan.png' },
+  { name: '[惊悚]', file: 'jingshu.png' },
+  { name: '[困~]', file: 'kun.png' },
+  { name: '[心碎]', file: 'xinsui.png' },
+  { name: '[天使]', file: 'tianshi.png' },
+  { name: '[晕]', file: 'yun.png' },
+  { name: '[啊]', file: 'a.png' },
+  { name: '[愤怒]', file: 'fennu.png' },
+  { name: '[睡着]', file: 'shuizhuo.png' },
+  { name: '[面无表情]', file: 'mianwubiaoqing.png' },
+  { name: '[难过]', file: 'nanguo.png' },
+  { name: '[犯困]', file: 'fankun.png' },
+  { name: '[好吃]', file: 'haochi.png' },
+  { name: '[呕吐]', file: 'outu.png' },
+  { name: '[龇牙]', file: 'ziya.png' },
+  { name: '[懵比]', file: 'mengbi.png' },
+  { name: '[白眼]', file: 'baiyan.png' },
+  { name: '[饿死]', file: 'esi.png' },
+  { name: '[凶]', file: 'xiong.png' },
+  { name: '[感冒]', file: 'ganmao.png' },
+  { name: '[流汗]', file: 'liuhan.png' },
+  { name: '[笑哭]', file: 'xiaoku.png' },
+  { name: '[流口水]', file: 'liukoushui.png' },
+  { name: '[尴尬]', file: 'ganga.png' },
+  { name: '[惊讶]', file: 'jingya.png' },
+  { name: '[大惊]', file: 'dajing.png' },
+  { name: '[不好意思]', file: 'buhaoyisi.png' },
+  { name: '[大闹]', file: 'danao.png' },
+  { name: '[不可思议]', file: 'bukesiyi.png' },
+  { name: '[爱你]', file: 'aini.png' },
+  { name: '[红心]', file: 'hongxin.png' },
+  { name: '[点赞]', file: 'dianzan.png' },
+  { name: '[恶魔]', file: 'emo.png' }
+]
+
+export interface Emoji {
+  name: string
+  url: string
+}
+
+export const emojiPage = {}
+emojiList.forEach((item, index) => {
+  if (!emojiPage[Math.floor(index / 30) + 1]) {
+    emojiPage[Math.floor(index / 30) + 1] = []
+  }
+  emojiPage[Math.floor(index / 30) + 1].push(item)
+})
+
+// 后端上传地址
+const staticUrl = import.meta.env.VITE_STATIC_URL
+// 后缀
+const suffix = '/static/img/chat/emoji/'
+
+// 处理表情
+export function replaceEmoji(data: string) {
+  let newData = data
+  if (typeof newData !== 'object') {
+    const reg = /\[(.+?)\]/g // [] 中括号
+    const zhEmojiName = newData.match(reg)
+    if (zhEmojiName) {
+      zhEmojiName.forEach((item) => {
+        const emojiFile = selEmojiFile(item)
+        newData = newData.replace(
+          item,
+          `<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${
+            staticUrl + suffix + emojiFile
+          }"/>`
+        )
+      })
+    }
+  }
+  return newData
+}
+
+// 获得所有表情
+export function getEmojiList(): Emoji[] {
+  return emojiList.map((item) => ({
+    url: staticUrl + suffix + item.file,
+    name: item.name
+  })) as Emoji[]
+}
+
+function selEmojiFile(name: string) {
+  for (const index in emojiList) {
+    if (emojiList[index].name === name) {
+      return emojiList[index].file
+    }
+  }
+  return false
+}

+ 5 - 0
src/views/mall/promotion/kefu/components/index.ts

@@ -0,0 +1,5 @@
+import KeFuConversationBox from './KeFuConversationBox.vue'
+import KeFuChatBox from './KeFuChatBox.vue'
+import * as Constants from './constants'
+
+export { KeFuConversationBox, KeFuChatBox, Constants }

+ 60 - 0
src/views/mall/promotion/kefu/index.vue

@@ -0,0 +1,60 @@
+<template>
+  <el-row :gutter="10">
+    <el-col :span="8">
+      <ContentWrap>
+        <KeFuConversationBox ref="keFuConversationRef" @change="handleChange" />
+      </ContentWrap>
+    </el-col>
+    <el-col :span="16">
+      <ContentWrap>
+        <KeFuChatBox ref="keFuChatBoxRef" />
+      </ContentWrap>
+    </el-col>
+  </el-row>
+</template>
+
+<script lang="ts" setup>
+import { KeFuChatBox, KeFuConversationBox } from './components'
+import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
+
+defineOptions({ name: 'KeFu' })
+
+// 加载消息
+const keFuChatBoxRef = ref<InstanceType<typeof KeFuChatBox>>()
+const handleChange = (conversation: KeFuConversationRespVO) => {
+  keFuChatBoxRef.value?.getMessageList(conversation)
+}
+
+// 加载会话
+const keFuConversationRef = ref<InstanceType<typeof KeFuConversationBox>>()
+onMounted(() => {
+  keFuConversationRef.value?.getConversationList()
+})
+</script>
+
+<style lang="scss">
+.kefu {
+  height: calc(100vh - 165px);
+  overflow: auto; /* 确保内容可滚动 */
+}
+
+/* 定义滚动条样式 */
+::-webkit-scrollbar {
+  width: 10px;
+  height: 6px;
+}
+
+/*定义滚动条轨道 内阴影+圆角*/
+::-webkit-scrollbar-track {
+  box-shadow: inset 0 0 0px rgba(240, 240, 240, 0.5);
+  border-radius: 10px;
+  background-color: #fff;
+}
+
+/*定义滑块 内阴影+圆角*/
+::-webkit-scrollbar-thumb {
+  border-radius: 10px;
+  box-shadow: inset 0 0 0px rgba(240, 240, 240, 0.5);
+  background-color: rgba(240, 240, 240, 0.5);
+}
+</style>

+ 1 - 0
types/env.d.ts

@@ -19,6 +19,7 @@ interface ImportMetaEnv {
   readonly VITE_UPLOAD_URL: string
   readonly VITE_API_URL: string
   readonly VITE_BASE_PATH: string
+  readonly VITE_STATIC_URL: string
   readonly VITE_DROP_DEBUGGER: string
   readonly VITE_DROP_CONSOLE: string
   readonly VITE_SOURCEMAP: string