houzhongjian
2024-08-08 820397e43a0b64d35c6d31d2a55475061438593b
提交 | 用户 | 时间
820397 1 <template>
H 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
73 const message = useMessage() // 消息弹窗
74 const { copy } = useClipboard() // 初始化 copy 到粘贴板
75 const userStore = useUserStore()
76
77 // 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
78 const messageContainer: any = ref(null)
79 const isScrolling = ref(false) //用于判断用户是否在滚动
80
81 const userAvatar = computed(() => userStore.user.avatar ?? userAvatarDefaultImg)
82 const roleAvatar = computed(() => props.conversation.roleAvatar ?? roleAvatarDefaultImg)
83
84 // 定义 props
85 const props = defineProps({
86   conversation: {
87     type: Object as PropType<ChatConversationVO>,
88     required: true
89   },
90   list: {
91     type: Array as PropType<ChatMessageVO[]>,
92     required: true
93   }
94 })
95
96 const { list } = toRefs(props) // 消息列表
97
98 const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']) // 定义 emits
99
100 // ============ 处理对话滚动 ==============
101
102 /** 滚动到底部 */
103 const scrollToBottom = async (isIgnore?: boolean) => {
104   // 注意要使用 nextTick 以免获取不到 dom
105   await nextTick()
106   if (isIgnore || !isScrolling.value) {
107     messageContainer.value.scrollTop =
108       messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
109   }
110 }
111
112 function handleScroll() {
113   const scrollContainer = messageContainer.value
114   const scrollTop = scrollContainer.scrollTop
115   const scrollHeight = scrollContainer.scrollHeight
116   const offsetHeight = scrollContainer.offsetHeight
117   if (scrollTop + offsetHeight < scrollHeight - 100) {
118     // 用户开始滚动并在最底部之上,取消保持在最底部的效果
119     isScrolling.value = true
120   } else {
121     // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
122     isScrolling.value = false
123   }
124 }
125
126 /** 回到底部 */
127 const handleGoBottom = async () => {
128   const scrollContainer = messageContainer.value
129   scrollContainer.scrollTop = scrollContainer.scrollHeight
130 }
131
132 /** 回到顶部 */
133 const handlerGoTop = async () => {
134   const scrollContainer = messageContainer.value
135   scrollContainer.scrollTop = 0
136 }
137
138 defineExpose({ scrollToBottom, handlerGoTop }) // 提供方法给 parent 调用
139
140 // ============ 处理消息操作 ==============
141
142 /** 复制 */
143 const copyContent = async (content) => {
144   await copy(content)
145   message.success('复制成功!')
146 }
147
148 /** 删除 */
149 const onDelete = async (id) => {
150   // 删除 message
151   await ChatMessageApi.deleteChatMessage(id)
152   message.success('删除成功!')
153   // 回调
154   emits('onDeleteSuccess')
155 }
156
157 /** 刷新 */
158 const onRefresh = async (message: ChatMessageVO) => {
159   emits('onRefresh', message)
160 }
161
162 /** 编辑 */
163 const onEdit = async (message: ChatMessageVO) => {
164   emits('onEdit', message)
165 }
166
167 /** 初始化 */
168 onMounted(async () => {
169   messageContainer.value.addEventListener('scroll', handleScroll)
170 })
171 </script>
172
173 <style scoped lang="scss">
174 .message-container {
175   position: relative;
176   overflow-y: scroll;
177 }
178
179 // 中间
180 .chat-list {
181   display: flex;
182   flex-direction: column;
183   overflow-y: hidden;
184   padding: 0 20px;
185   .message-item {
186     margin-top: 50px;
187   }
188
189   .left-message {
190     display: flex;
191     flex-direction: row;
192   }
193
194   .right-message {
195     display: flex;
196     flex-direction: row-reverse;
197     justify-content: flex-start;
198   }
199
200   .message {
201     display: flex;
202     flex-direction: column;
203     text-align: left;
204     margin: 0 15px;
205
206     .time {
207       text-align: left;
208       line-height: 30px;
209     }
210
211     .left-text-container {
212       position: relative;
213       display: flex;
214       flex-direction: column;
215       overflow-wrap: break-word;
216       background-color: rgba(228, 228, 228, 0.8);
217       box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8);
218       border-radius: 10px;
219       padding: 10px 10px 5px 10px;
220
221       .left-text {
222         color: #393939;
223         font-size: 0.95rem;
224       }
225     }
226
227     .right-text-container {
228       display: flex;
229       flex-direction: row-reverse;
230
231       .right-text {
232         font-size: 0.95rem;
233         color: #fff;
234         display: inline;
235         background-color: #267fff;
236         box-shadow: 0 0 0 1px #267fff;
237         border-radius: 10px;
238         padding: 10px;
239         width: auto;
240         overflow-wrap: break-word;
241         white-space: pre-wrap;
242       }
243     }
244
245     .left-btns {
246       display: flex;
247       flex-direction: row;
248       margin-top: 8px;
249     }
250
251     .right-btns {
252       display: flex;
253       flex-direction: row-reverse;
254       margin-top: 8px;
255     }
256   }
257
258   // 复制、删除按钮
259   .btn-cus {
260     display: flex;
261     background-color: transparent;
262     align-items: center;
263
264     .btn-image {
265       height: 20px;
266     }
267   }
268
269   .btn-cus:hover {
270     cursor: pointer;
271     background-color: #f6f6f6;
272   }
273 }
274
275 // 回到底部
276 .to-bottom {
277   position: absolute;
278   z-index: 1000;
279   bottom: 0;
280   right: 50%;
281 }
282 </style>