houzhongjian
2024-08-08 820397e43a0b64d35c6d31d2a55475061438593b
提交 | 用户 | 时间
820397 1 <!--  AI 对话  -->
H 2 <template>
3   <el-aside width="260px" class="conversation-container h-100%">
4     <!-- 左顶部:对话 -->
5     <div class="h-100%">
6       <el-button class="w-1/1 btn-new-conversation" type="primary" @click="createConversation">
7         <Icon icon="ep:plus" class="mr-5px" />
8         新建对话
9       </el-button>
10
11       <!-- 左顶部:搜索对话 -->
12       <el-input
13         v-model="searchName"
14         size="large"
15         class="mt-10px search-input"
16         placeholder="搜索历史记录"
17         @keyup="searchConversation"
18       >
19         <template #prefix>
20           <Icon icon="ep:search" />
21         </template>
22       </el-input>
23
24       <!-- 左中间:对话列表 -->
25       <div class="conversation-list">
26         <!-- 情况一:加载中 -->
27         <el-empty v-if="loading" description="." :v-loading="loading" />
28         <!-- 情况二:按照 group 分组,展示聊天会话 list 列表 -->
29         <div v-for="conversationKey in Object.keys(conversationMap)" :key="conversationKey">
30           <div
31             class="conversation-item classify-title"
32             v-if="conversationMap[conversationKey].length"
33           >
34             <el-text class="mx-1" size="small" tag="b">{{ conversationKey }}</el-text>
35           </div>
36           <div
37             class="conversation-item"
38             v-for="conversation in conversationMap[conversationKey]"
39             :key="conversation.id"
40             @click="handleConversationClick(conversation.id)"
41             @mouseover="hoverConversationId = conversation.id"
42             @mouseout="hoverConversationId = ''"
43           >
44             <div
45               :class="
46                 conversation.id === activeConversationId ? 'conversation active' : 'conversation'
47               "
48             >
49               <div class="title-wrapper">
50                 <img class="avatar" :src="conversation.roleAvatar || roleAvatarDefaultImg" />
51                 <span class="title">{{ conversation.title }}</span>
52               </div>
53               <div class="button-wrapper" v-show="hoverConversationId === conversation.id">
54                 <el-button class="btn" link @click.stop="handleTop(conversation)">
55                   <el-icon title="置顶" v-if="!conversation.pinned"><Top /></el-icon>
56                   <el-icon title="置顶" v-if="conversation.pinned"><Bottom /></el-icon>
57                 </el-button>
58                 <el-button class="btn" link @click.stop="updateConversationTitle(conversation)">
59                   <el-icon title="编辑">
60                     <Icon icon="ep:edit" />
61                   </el-icon>
62                 </el-button>
63                 <el-button class="btn" link @click.stop="deleteChatConversation(conversation)">
64                   <el-icon title="删除对话">
65                     <Icon icon="ep:delete" />
66                   </el-icon>
67                 </el-button>
68               </div>
69             </div>
70           </div>
71         </div>
72         <!-- 底部占位  -->
73         <div class="h-160px w-100%"></div>
74       </div>
75     </div>
76
77     <!-- 左底部:工具栏 -->
78     <div class="tool-box">
79       <div @click="handleRoleRepository">
80         <Icon icon="ep:user" />
81         <el-text size="small">角色仓库</el-text>
82       </div>
83       <div @click="handleClearConversation">
84         <Icon icon="ep:delete" />
85         <el-text size="small">清空未置顶对话</el-text>
86       </div>
87     </div>
88
89     <!-- 角色仓库抽屉 -->
90     <el-drawer v-model="roleRepositoryOpen" title="角色仓库" size="754px">
91       <RoleRepository />
92     </el-drawer>
93   </el-aside>
94 </template>
95
96 <script setup lang="ts">
97 import { ChatConversationApi, ChatConversationVO } from '@/api/ai/chat/conversation'
98 import RoleRepository from '../role/RoleRepository.vue'
99 import { Bottom, Top } from '@element-plus/icons-vue'
100 import roleAvatarDefaultImg from '@/assets/ai/gpt.svg'
101
102 const message = useMessage() // 消息弹窗
103
104 // 定义属性
105 const searchName = ref<string>('') // 对话搜索
106 const activeConversationId = ref<number | null>(null) // 选中的对话,默认为 null
107 const hoverConversationId = ref<number | null>(null) // 悬浮上去的对话
108 const conversationList = ref([] as ChatConversationVO[]) // 对话列表
109 const conversationMap = ref<any>({}) // 对话分组 (置顶、今天、三天前、一星期前、一个月前)
110 const loading = ref<boolean>(false) // 加载中
111 const loadingTime = ref<any>() // 加载中定时器
112
113 // 定义组件 props
114 const props = defineProps({
115   activeId: {
116     type: String || null,
117     required: true
118   }
119 })
120
121 // 定义钩子
122 const emits = defineEmits([
123   'onConversationCreate',
124   'onConversationClick',
125   'onConversationClear',
126   'onConversationDelete'
127 ])
128
129 /** 搜索对话 */
130 const searchConversation = async (e) => {
131   // 恢复数据
132   if (!searchName.value.trim().length) {
133     conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
134   } else {
135     // 过滤
136     const filterValues = conversationList.value.filter((item) => {
137       return item.title.includes(searchName.value.trim())
138     })
139     conversationMap.value = await getConversationGroupByCreateTime(filterValues)
140   }
141 }
142
143 /** 点击对话 */
144 const handleConversationClick = async (id: number) => {
145   // 过滤出选中的对话
146   const filterConversation = conversationList.value.filter((item) => {
147     return item.id === id
148   })
149   // 回调 onConversationClick
150   // noinspection JSVoidFunctionReturnValueUsed
151   const success = emits('onConversationClick', filterConversation[0])
152   // 切换对话
153   if (success) {
154     activeConversationId.value = id
155   }
156 }
157
158 /** 获取对话列表 */
159 const getChatConversationList = async () => {
160   try {
161     // 加载中
162     loadingTime.value = setTimeout(() => {
163       loading.value = true
164     }, 50)
165
166     // 1.1 获取 对话数据
167     conversationList.value = await ChatConversationApi.getChatConversationMyList()
168     // 1.2 排序
169     conversationList.value.sort((a, b) => {
170       return b.createTime - a.createTime
171     })
172     // 1.3 没有任何对话情况
173     if (conversationList.value.length === 0) {
174       activeConversationId.value = null
175       conversationMap.value = {}
176       return
177     }
178
179     // 2. 对话根据时间分组(置顶、今天、一天前、三天前、七天前、30 天前)
180     conversationMap.value = await getConversationGroupByCreateTime(conversationList.value)
181   } finally {
182     // 清理定时器
183     if (loadingTime.value) {
184       clearTimeout(loadingTime.value)
185     }
186     // 加载完成
187     loading.value = false
188   }
189 }
190
191 /** 按照 creteTime 创建时间,进行分组 */
192 const getConversationGroupByCreateTime = async (list: ChatConversationVO[]) => {
193   // 排序、指定、时间分组(今天、一天前、三天前、七天前、30天前)
194   // noinspection NonAsciiCharacters
195   const groupMap = {
196     置顶: [],
197     今天: [],
198     一天前: [],
199     三天前: [],
200     七天前: [],
201     三十天前: []
202   }
203   // 当前时间的时间戳
204   const now = Date.now()
205   // 定义时间间隔常量(单位:毫秒)
206   const oneDay = 24 * 60 * 60 * 1000
207   const threeDays = 3 * oneDay
208   const sevenDays = 7 * oneDay
209   const thirtyDays = 30 * oneDay
210   for (const conversation of list) {
211     // 置顶
212     if (conversation.pinned) {
213       groupMap['置顶'].push(conversation)
214       continue
215     }
216     // 计算时间差(单位:毫秒)
217     const diff = now - conversation.createTime
218     // 根据时间间隔判断
219     if (diff < oneDay) {
220       groupMap['今天'].push(conversation)
221     } else if (diff < threeDays) {
222       groupMap['一天前'].push(conversation)
223     } else if (diff < sevenDays) {
224       groupMap['三天前'].push(conversation)
225     } else if (diff < thirtyDays) {
226       groupMap['七天前'].push(conversation)
227     } else {
228       groupMap['三十天前'].push(conversation)
229     }
230   }
231   return groupMap
232 }
233
234 /** 新建对话 */
235 const createConversation = async () => {
236   // 1. 新建对话
237   const conversationId = await ChatConversationApi.createChatConversationMy(
238     {} as unknown as ChatConversationVO
239   )
240   // 2. 获取对话内容
241   await getChatConversationList()
242   // 3. 选中对话
243   await handleConversationClick(conversationId)
244   // 4. 回调
245   emits('onConversationCreate')
246 }
247
248 /** 修改对话的标题 */
249 const updateConversationTitle = async (conversation: ChatConversationVO) => {
250   // 1. 二次确认
251   const { value } = await ElMessageBox.prompt('修改标题', {
252     inputPattern: /^[\s\S]*.*\S[\s\S]*$/, // 判断非空,且非空格
253     inputErrorMessage: '标题不能为空',
254     inputValue: conversation.title
255   })
256   // 2. 发起修改
257   await ChatConversationApi.updateChatConversationMy({
258     id: conversation.id,
259     title: value
260   } as ChatConversationVO)
261   message.success('重命名成功')
262   // 3. 刷新列表
263   await getChatConversationList()
264   // 4. 过滤当前切换的
265   const filterConversationList = conversationList.value.filter((item) => {
266     return item.id === conversation.id
267   })
268   if (filterConversationList.length > 0) {
269     // tip:避免切换对话
270     if (activeConversationId.value === filterConversationList[0].id) {
271       emits('onConversationClick', filterConversationList[0])
272     }
273   }
274 }
275
276 /** 删除聊天对话 */
277 const deleteChatConversation = async (conversation: ChatConversationVO) => {
278   try {
279     // 删除的二次确认
280     await message.delConfirm(`是否确认删除对话 - ${conversation.title}?`)
281     // 发起删除
282     await ChatConversationApi.deleteChatConversationMy(conversation.id)
283     message.success('对话已删除')
284     // 刷新列表
285     await getChatConversationList()
286     // 回调
287     emits('onConversationDelete', conversation)
288   } catch {}
289 }
290
291 /** 清空对话 */
292 const handleClearConversation = async () => {
293   try {
294     await message.confirm('确认后对话会全部清空,置顶的对话除外。')
295     await ChatConversationApi.deleteChatConversationMyByUnpinned()
296     ElMessage({
297       message: '操作成功!',
298       type: 'success'
299     })
300     // 清空 对话 和 对话内容
301     activeConversationId.value = null
302     // 获取 对话列表
303     await getChatConversationList()
304     // 回调 方法
305     emits('onConversationClear')
306   } catch {}
307 }
308
309 /** 对话置顶 */
310 const handleTop = async (conversation: ChatConversationVO) => {
311   // 更新对话置顶
312   conversation.pinned = !conversation.pinned
313   await ChatConversationApi.updateChatConversationMy(conversation)
314   // 刷新对话
315   await getChatConversationList()
316 }
317
318 // ============ 角色仓库 ============
319
320 /** 角色仓库抽屉 */
321 const roleRepositoryOpen = ref<boolean>(false) // 角色仓库是否打开
322 const handleRoleRepository = async () => {
323   roleRepositoryOpen.value = !roleRepositoryOpen.value
324 }
325
326 /** 监听选中的对话 */
327 const { activeId } = toRefs(props)
328 watch(activeId, async (newValue, oldValue) => {
329   activeConversationId.value = newValue as string
330 })
331
332 // 定义 public 方法
333 defineExpose({ createConversation })
334
335 /** 初始化 */
336 onMounted(async () => {
337   // 获取 对话列表
338   await getChatConversationList()
339   // 默认选中
340   if (props.activeId) {
341     activeConversationId.value = props.activeId
342   } else {
343     // 首次默认选中第一个
344     if (conversationList.value.length) {
345       activeConversationId.value = conversationList.value[0].id
346       // 回调 onConversationClick
347       await emits('onConversationClick', conversationList.value[0])
348     }
349   }
350 })
351 </script>
352
353 <style scoped lang="scss">
354 .conversation-container {
355   position: relative;
356   display: flex;
357   flex-direction: column;
358   justify-content: space-between;
359   padding: 10px 10px 0;
360   overflow: hidden;
361
362   .btn-new-conversation {
363     padding: 18px 0;
364   }
365
366   .search-input {
367     margin-top: 20px;
368   }
369
370   .conversation-list {
371     overflow: auto;
372     height: 100%;
373
374     .classify-title {
375       padding-top: 10px;
376     }
377
378     .conversation-item {
379       margin-top: 5px;
380     }
381
382     .conversation {
383       display: flex;
384       flex-direction: row;
385       justify-content: space-between;
386       flex: 1;
387       padding: 0 5px;
388       cursor: pointer;
389       border-radius: 5px;
390       align-items: center;
391       line-height: 30px;
392
393       &.active {
394         background-color: #e6e6e6;
395
396         .button {
397           display: inline-block;
398         }
399       }
400
401       .title-wrapper {
402         display: flex;
403         flex-direction: row;
404         align-items: center;
405       }
406
407       .title {
408         padding: 2px 10px;
409         max-width: 220px;
410         font-size: 14px;
411         font-weight: 400;
412         color: rgba(0, 0, 0, 0.77);
413         overflow: hidden;
414         white-space: nowrap;
415         text-overflow: ellipsis;
416       }
417
418       .avatar {
419         width: 25px;
420         height: 25px;
421         border-radius: 5px;
422         display: flex;
423         flex-direction: row;
424         justify-items: center;
425       }
426
427       // 对话编辑、删除
428       .button-wrapper {
429         right: 2px;
430         display: flex;
431         flex-direction: row;
432         justify-items: center;
433         color: #606266;
434
435         .btn {
436           margin: 0;
437         }
438       }
439     }
440   }
441
442   // 角色仓库、清空未设置对话
443   .tool-box {
444     position: absolute;
445     bottom: 0;
446     left: 0;
447     right: 0;
448     //width: 100%;
449     padding: 0 20px;
450     background-color: #f4f4f4;
451     box-shadow: 0 0 1px 1px rgba(228, 228, 228, 0.8);
452     line-height: 35px;
453     display: flex;
454     justify-content: space-between;
455     align-items: center;
456     color: var(--el-text-color);
457
458     > div {
459       display: flex;
460       align-items: center;
461       color: #606266;
462       padding: 0;
463       margin: 0;
464       cursor: pointer;
465
466       > span {
467         margin-left: 5px;
468       }
469     }
470   }
471 }
472 </style>