KeFuChatBox.vue 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. <template>
  2. <el-container v-if="showChatBox" class="kefu">
  3. <el-header>
  4. <div class="kefu-title">{{ keFuConversation.userNickname }}</div>
  5. </el-header>
  6. <el-main class="kefu-content" style="overflow: visible">
  7. <el-scrollbar ref="scrollbarRef" always height="calc(100vh - 495px)">
  8. <div ref="innerRef" class="w-[100%] pb-3px">
  9. <div
  10. v-for="item in messageList"
  11. :key="item.id"
  12. :class="[
  13. item.senderType === UserTypeEnum.MEMBER
  14. ? `ss-row-left`
  15. : item.senderType === UserTypeEnum.ADMIN
  16. ? `ss-row-right`
  17. : ''
  18. ]"
  19. class="flex mb-20px w-[100%]"
  20. >
  21. <el-avatar
  22. v-show="item.senderType === UserTypeEnum.MEMBER"
  23. :src="keFuConversation.userAvatar"
  24. alt="avatar"
  25. />
  26. <div class="kefu-message p-10px">
  27. <!-- TODO puhui999: 消息相关等后续完成后统一抽离封装 -->
  28. <!-- 文本消息 -->
  29. <template v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType">
  30. <div
  31. v-dompurify-html="replaceEmoji(item.content)"
  32. :class="[
  33. item.senderType === UserTypeEnum.MEMBER
  34. ? `ml-10px`
  35. : item.senderType === UserTypeEnum.ADMIN
  36. ? `mr-10px`
  37. : ''
  38. ]"
  39. class="flex items-center"
  40. ></div>
  41. </template>
  42. <!-- 图片消息 -->
  43. <template v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType">
  44. <div
  45. :class="[
  46. item.senderType === UserTypeEnum.MEMBER
  47. ? `ml-10px`
  48. : item.senderType === UserTypeEnum.ADMIN
  49. ? `mr-10px`
  50. : ''
  51. ]"
  52. class="flex items-center"
  53. >
  54. <el-image
  55. :src="item.content"
  56. fit="contain"
  57. style="width: 200px; height: 200px"
  58. @click="imagePreview(item.content)"
  59. />
  60. </div>
  61. </template>
  62. </div>
  63. <el-avatar
  64. v-show="item.senderType === UserTypeEnum.ADMIN"
  65. :src="item.senderAvatar"
  66. alt="avatar"
  67. />
  68. </div>
  69. </div>
  70. </el-scrollbar>
  71. </el-main>
  72. <el-footer height="230px">
  73. <div class="h-[100%]">
  74. <div class="chat-tools">
  75. <EmojiSelectPopover @select-emoji="handleEmojiSelect" />
  76. </div>
  77. <el-input v-model="message" :rows="6" type="textarea" />
  78. <div class="h-45px flex justify-end">
  79. <el-button class="mt-10px" type="primary" @click="handleSendMessage">发送</el-button>
  80. </div>
  81. </div>
  82. </el-footer>
  83. </el-container>
  84. <el-empty v-else description="请选择左侧的一个会话后开始" />
  85. </template>
  86. <script lang="ts" setup>
  87. import { ElScrollbar as ElScrollbarType } from 'element-plus'
  88. import { KeFuMessageApi, KeFuMessageRespVO } from '@/api/mall/promotion/kefu/message'
  89. import { KeFuConversationRespVO } from '@/api/mall/promotion/kefu/conversation'
  90. import EmojiSelectPopover from './EmojiSelectPopover.vue'
  91. import { Emoji, replaceEmoji } from './emoji'
  92. import { KeFuMessageContentTypeEnum } from './constants'
  93. import { isEmpty } from '@/utils/is'
  94. import { UserTypeEnum } from '@/utils/constants'
  95. import { createImageViewer } from '@/components/ImageViewer'
  96. defineOptions({ name: 'KeFuMessageBox' })
  97. const messageTool = useMessage()
  98. const message = ref('') // 消息
  99. const messageList = ref<KeFuMessageRespVO[]>([]) // 消息列表
  100. const keFuConversation = ref<KeFuConversationRespVO>({} as KeFuConversationRespVO) // 用户会话
  101. const poller = ref<any>(null) // TODO puhui999: 轮训定时器,暂时模拟 websocket
  102. // 获得消息 TODO puhui999: 先不考虑下拉加载历史消息
  103. const getMessageList = async (conversation: KeFuConversationRespVO) => {
  104. keFuConversation.value = conversation
  105. const { list } = await KeFuMessageApi.getKeFuMessagePage({
  106. pageNo: 1,
  107. conversationId: conversation.id
  108. })
  109. messageList.value = list.reverse()
  110. // TODO puhui999: 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
  111. await scrollToBottom()
  112. // TODO puhui999: 轮训相关,功能完善后移除
  113. if (!poller.value) {
  114. poller.value = setInterval(() => {
  115. getMessageList(conversation)
  116. }, 1000)
  117. }
  118. }
  119. defineExpose({ getMessageList })
  120. // 是否显示聊天区域
  121. const showChatBox = computed(() => !isEmpty(keFuConversation.value))
  122. // 处理表情选择
  123. const handleEmojiSelect = (item: Emoji) => {
  124. message.value += item.name
  125. }
  126. // 发送消息
  127. const handleSendMessage = async () => {
  128. // 1. 校验消息是否为空
  129. if (isEmpty(unref(message.value))) {
  130. messageTool.warning('请输入消息后再发送哦!')
  131. }
  132. // 2. 组织发送消息
  133. const msg = {
  134. conversationId: keFuConversation.value.id,
  135. contentType: KeFuMessageContentTypeEnum.TEXT,
  136. content: message.value
  137. }
  138. await KeFuMessageApi.sendKeFuMessage(msg)
  139. message.value = ''
  140. // 3. 加载消息列表
  141. await getMessageList(keFuConversation.value)
  142. // 滚动到最新消息处
  143. await scrollToBottom()
  144. }
  145. const innerRef = ref<HTMLDivElement>()
  146. const scrollbarRef = ref<InstanceType<typeof ElScrollbarType>>()
  147. // 滚动到底部
  148. const scrollToBottom = async () => {
  149. await nextTick()
  150. scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
  151. }
  152. /** 图预览 */
  153. const imagePreview = (imgUrl: string) => {
  154. createImageViewer({
  155. urlList: [imgUrl]
  156. })
  157. }
  158. // TODO puhui999: 轮训相关,功能完善后移除
  159. onBeforeUnmount(() => {
  160. if (!poller.value) {
  161. return
  162. }
  163. clearInterval(poller.value)
  164. })
  165. </script>
  166. <style lang="scss" scoped>
  167. .kefu {
  168. &-title {
  169. border-bottom: #e4e0e0 solid 1px;
  170. height: 60px;
  171. line-height: 60px;
  172. }
  173. &-content {
  174. .ss-row-left {
  175. justify-content: flex-start;
  176. .kefu-message {
  177. margin-left: 20px;
  178. position: relative;
  179. &::before {
  180. content: '';
  181. width: 10px;
  182. height: 10px;
  183. left: -19px;
  184. top: calc(50% - 10px);
  185. position: absolute;
  186. border-left: 5px solid transparent;
  187. border-bottom: 5px solid transparent;
  188. border-top: 5px solid transparent;
  189. border-right: 5px solid #ffffff;
  190. }
  191. }
  192. }
  193. .ss-row-right {
  194. justify-content: flex-end;
  195. .kefu-message {
  196. margin-right: 20px;
  197. position: relative;
  198. &::after {
  199. content: '';
  200. width: 10px;
  201. height: 10px;
  202. right: -19px;
  203. top: calc(50% - 10px);
  204. position: absolute;
  205. border-left: 5px solid #ffffff;
  206. border-bottom: 5px solid transparent;
  207. border-top: 5px solid transparent;
  208. border-right: 5px solid transparent;
  209. }
  210. }
  211. }
  212. // 消息气泡
  213. .kefu-message {
  214. color: #333;
  215. border-radius: 5px;
  216. box-shadow: 3px 5px 15px rgba(0, 0, 0, 0.2);
  217. padding: 5px 10px;
  218. width: auto;
  219. max-width: 50%;
  220. text-align: left;
  221. display: inline-block !important;
  222. position: relative;
  223. word-break: break-all;
  224. background-color: #ffffff;
  225. transition: all 0.2s;
  226. &:hover {
  227. transform: scale(1.03);
  228. }
  229. }
  230. }
  231. .chat-tools {
  232. width: 100%;
  233. border: #e4e0e0 solid 1px;
  234. height: 44px;
  235. display: flex;
  236. align-items: center;
  237. }
  238. ::v-deep(textarea) {
  239. resize: none;
  240. }
  241. }
  242. </style>