MessageList.vue 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. <template>
  2. <div ref="messageContainer" class="h-100% overflow-y-auto relative">
  3. <div class="chat-list" v-for="(item, index) in list" :key="index">
  4. <!-- 靠左 message:system、assistant 类型 -->
  5. <div class="left-message message-item" v-if="item.type !== 'user'">
  6. <div class="avatar">
  7. <el-avatar :src="roleAvatar" />
  8. </div>
  9. <div class="message">
  10. <div>
  11. <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
  12. </div>
  13. <div class="left-text-container" ref="markdownViewRef">
  14. <MarkdownView class="left-text" :content="item.content" />
  15. </div>
  16. <div class="left-btns">
  17. <el-button class="btn-cus" link @click="copyContent(item.content)">
  18. <img class="btn-image" src="@/assets/ai/copy.svg" />
  19. </el-button>
  20. <el-button v-if="item.id > 0" class="btn-cus" link @click="onDelete(item.id)">
  21. <img class="btn-image h-17px" src="@/assets/ai/delete.svg" />
  22. </el-button>
  23. </div>
  24. </div>
  25. </div>
  26. <!-- 靠右 message:user 类型 -->
  27. <div class="right-message message-item" v-if="item.type === 'user'">
  28. <div class="avatar">
  29. <el-avatar :src="userAvatar" />
  30. </div>
  31. <div class="message">
  32. <div>
  33. <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
  34. </div>
  35. <div class="right-text-container">
  36. <div class="right-text">{{ item.content }}</div>
  37. </div>
  38. <div class="right-btns">
  39. <el-button class="btn-cus" link @click="copyContent(item.content)">
  40. <img class="btn-image" src="@/assets/ai/copy.svg" />
  41. </el-button>
  42. <el-button class="btn-cus" link @click="onDelete(item.id)">
  43. <img class="btn-image h-17px mr-12px" src="@/assets/ai/delete.svg" />
  44. </el-button>
  45. <el-button class="btn-cus" link @click="onRefresh(item)">
  46. <el-icon size="17"><RefreshRight /></el-icon>
  47. </el-button>
  48. <el-button class="btn-cus" link @click="onEdit(item)">
  49. <el-icon size="17"><Edit /></el-icon>
  50. </el-button>
  51. </div>
  52. </div>
  53. </div>
  54. </div>
  55. </div>
  56. <!-- 回到底部 -->
  57. <div v-if="isScrolling" class="to-bottom" @click="handleGoBottom">
  58. <el-button :icon="ArrowDownBold" circle />
  59. </div>
  60. </template>
  61. <script setup lang="ts">
  62. import { PropType } from 'vue'
  63. import { formatDate } from '@/utils/formatTime'
  64. import MarkdownView from '@/components/MarkdownView/index.vue'
  65. import { useClipboard } from '@vueuse/core'
  66. import { ArrowDownBold, Edit, RefreshRight } from '@element-plus/icons-vue'
  67. import { ChatMessageApi, ChatMessageVO } from '@/api/ai/chat/message'
  68. import { ChatConversationVO } from '@/api/ai/chat/conversation'
  69. import { useUserStore } from '@/store/modules/user'
  70. import userAvatarDefaultImg from '@/assets/imgs/avatar.gif'
  71. import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
  72. const message = useMessage() // 消息弹窗
  73. const { copy } = useClipboard() // 初始化 copy 到粘贴板
  74. const userStore = useUserStore()
  75. // 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
  76. const messageContainer: any = ref(null)
  77. const isScrolling = ref(false) //用于判断用户是否在滚动
  78. const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
  79. const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
  80. // 定义 props
  81. const props = defineProps({
  82. conversation: {
  83. type: Object as PropType<ChatConversationVO>,
  84. required: true
  85. },
  86. list: {
  87. type: Array as PropType<ChatMessageVO[]>,
  88. required: true
  89. }
  90. })
  91. const { list } = toRefs(props) // 消息列表
  92. const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits
  93. // ============ 处理对话滚动 ==============
  94. /** 滚动到底部 */
  95. const scrollToBottom = async (isIgnore?: boolean) => {
  96. // 注意要使用 nextTick 以免获取不到 dom
  97. await nextTick()
  98. if (isIgnore || !isScrolling.value) {
  99. messageContainer.value.scrollTop =
  100. messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
  101. }
  102. }
  103. function handleScroll() {
  104. const scrollContainer = messageContainer.value
  105. const scrollTop = scrollContainer.scrollTop
  106. const scrollHeight = scrollContainer.scrollHeight
  107. const offsetHeight = scrollContainer.offsetHeight
  108. if (scrollTop + offsetHeight < scrollHeight - 100) {
  109. // 用户开始滚动并在最底部之上,取消保持在最底部的效果
  110. isScrolling.value = true
  111. } else {
  112. // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
  113. isScrolling.value = false
  114. }
  115. }
  116. /** 回到底部 */
  117. const handleGoBottom = async () => {
  118. const scrollContainer = messageContainer.value
  119. scrollContainer.scrollTop = scrollContainer.scrollHeight
  120. }
  121. /** 回到顶部 */
  122. const handlerGoTop = async () => {
  123. const scrollContainer = messageContainer.value
  124. scrollContainer.scrollTop = 0
  125. }
  126. defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用
  127. // ============ 处理消息操作 ==============
  128. /** 复制 */
  129. const copyContent = async (content) => {
  130. await copy(content)
  131. message.success('复制成功!')
  132. }
  133. /** 删除 */
  134. const onDelete = async (id) => {
  135. // 删除 message
  136. await ChatMessageApi.deleteChatMessage(id)
  137. message.success('删除成功!')
  138. // 回调
  139. emits('onDeleteSuccess')
  140. }
  141. /** 刷新 */
  142. const onRefresh = async (message: ChatMessageVO) => {
  143. emits('onRefresh', message)
  144. }
  145. /** 编辑 */
  146. const onEdit = async (message: ChatMessageVO) => {
  147. emits('onEdit', message)
  148. }
  149. /** 初始化 */
  150. onMounted(async () => {
  151. messageContainer.value.addEventListener('scroll', handleScroll)
  152. })
  153. </script>
  154. <style scoped lang="scss">
  155. .message-container {
  156. position: relative;
  157. overflow-y: scroll;
  158. }
  159. // 中间
  160. .chat-list {
  161. display: flex;
  162. flex-direction: column;
  163. overflow-y: hidden;
  164. padding: 0 20px;
  165. .message-item {
  166. margin-top: 50px;
  167. }
  168. .left-message {
  169. display: flex;
  170. flex-direction: row;
  171. }
  172. .right-message {
  173. display: flex;
  174. flex-direction: row-reverse;
  175. justify-content: flex-start;
  176. }
  177. .message {
  178. display: flex;
  179. flex-direction: column;
  180. text-align: left;
  181. margin: 0 15px;
  182. .time {
  183. text-align: left;
  184. line-height: 30px;
  185. }
  186. .left-text-container {
  187. position: relative;
  188. display: flex;
  189. flex-direction: column;
  190. overflow-wrap: break-word;
  191. background-color: rgba(228, 228, 228, 0.8);
  192. box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8);
  193. border-radius: 10px;
  194. padding: 10px 10px 5px 10px;
  195. .left-text {
  196. color: #393939;
  197. font-size: 0.95rem;
  198. }
  199. }
  200. .right-text-container {
  201. display: flex;
  202. flex-direction: row-reverse;
  203. .right-text {
  204. font-size: 0.95rem;
  205. color: #fff;
  206. display: inline;
  207. background-color: #267fff;
  208. box-shadow: 0 0 0 1px #267fff;
  209. border-radius: 10px;
  210. padding: 10px;
  211. width: auto;
  212. overflow-wrap: break-word;
  213. white-space: pre-wrap;
  214. }
  215. }
  216. .left-btns {
  217. display: flex;
  218. flex-direction: row;
  219. margin-top: 8px;
  220. }
  221. .right-btns {
  222. display: flex;
  223. flex-direction: row-reverse;
  224. margin-top: 8px;
  225. }
  226. }
  227. // 复制、删除按钮
  228. .btn-cus {
  229. display: flex;
  230. background-color: transparent;
  231. align-items: center;
  232. .btn-image {
  233. height: 20px;
  234. }
  235. }
  236. .btn-cus:hover {
  237. cursor: pointer;
  238. background-color: #f6f6f6;
  239. }
  240. }
  241. // 回到底部
  242. .to-bottom {
  243. position: absolute;
  244. z-index: 1000;
  245. bottom: 0;
  246. right: 50%;
  247. }
  248. </style>