MessageList.vue 7.9 KB

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