项目引入DeepSeek对话【前端】
使用技术栈:vue3+pina
本文章主要实现前端页面展示(功能项如下):
1.实现基本对话功能。
2.实现聊条记录回显功能。实现分页展示,每次刷新展示最后一页数据,滚动条在最下方展示。
3.实现拖拽头部在项目展示区域内任意移动功能。
4.实现上下左右鼠标放在边上可以自由放大缩小功能。
注:每次实时对话都可以缓存本地,退出登录或者清除缓存就清空。每次联调历史记录对话,为了优化频繁调接口,可以在第一次点开的时候存值后续不需要调用接口。
实现效果:
看代码注释学习:
<template><divv-if="open"class="chat-container"ref="chatContainer":style="{width: containerWidth + 'px',height: containerHeight + 'px',top: containerTop + 'px',left: containerLeft + 'px'}"@mousedown="startResize"><!-- 顶部导航(可拖拽区域) --><div class="chat-header" @mousedown.stop="startDrag"><div class="headerTitle"><span> 降碳小助手</span></div><!-- 关闭按钮 --><!-- <div class="header-controls"><div class="closeButton" @click="closeChange" :disabled="false"><CloseOutlined /></div></div> --></div><div class="chatDom"><!-- 聊天区 --><div class="chatBox"><div class="chat-messages"><!-- 没有对话的默认数据 --><div v-if="messages.length == 0" class="empty-state"><div class="empty-icon"><i class="fa fa-comments text-5xl text-indigo-300"></i></div><p class="empty-text">开始对话吧</p></div><!-- 对话 --><div v-for="(msg, index) in messages" :key="index" class="message-item"><div v-if="msg.role === 'user'" class="user-message"><div class="message-bubble user-bubble">{{ msg.content }}</div></div><div v-else class="ai-message"><div class="ai-avatar"><i class="fa fa-robot text-white"></i></div><div class="message-bubble ai-bubble"><template v-if="msg.loading"><LoadingOutlined /></template><template v-else><div class="ai-content" v-html="formatContent(msg.content)"></div></template></div></div></div></div><!-- 聊天框 --><div class="chat-input-area"><textareav-model="userInput"@keydown.enter.exact.prevent="sendMessage"@keydown.enter.shift="handleShiftEnter":disabled="isLoading"placeholder="输入消息(Shift+Enter换行,Enter发送)..."class="message-input"@mousedown.stop></textarea><div class="operation-btn"><div class="chart-send" @click="chatRecords">聊天记录</div><div @click="sendMessage" :disabled="!userInput.trim() || isLoading" class="send-btn">发送</div></div></div></div><!-- 聊天记录区 --><div class="chatRecords" v-if="recordShow"><div class="chat-messages"><div v-if="recordList.length == 0" class="empty-state"><div class="empty-icon"><i class="fa fa-comments text-5xl text-indigo-300"></i></div><p class="empty-text">暂无历史记录</p></div><div v-for="(msg, index) in recordList" :key="index" class="message-item"><div v-if="msg.role === 'user'" class="user-message"><div class="message-bubble user-bubble">{{ msg.content }}</div></div><div v-else class="ai-message"><div class="ai-avatar"><i class="fa fa-robot text-white"></i></div><div class="message-bubble ai-bubble"><template v-if="msg.loading"><LoadingOutlined /></template><template v-else><div class="ai-content" v-html="formatContent(msg.content)"></div></template></div></div></div></div><!-- 聊天记录关键词搜索 --><div v-show="searchShow" class="searchDom"><a-input-searchv-model:value="searchKey"placeholder="输入关键词"@search="onSearch"@mousedown.stop></a-input-search></div><div class="papDom" v-if="recordShow"><div class="searchDom"><div ref="searchDom" @click="searchChange"><SearchOutlined /></div><a-paginationv-model:current="current":page-size="size":total="total"simple@change="handlePageChange"/></div></div></div></div></div>
</template><script setup>import { ref, onMounted, nextTick, watch } from 'vue'import { globalStore, viewTagsStore } from '@/store'import tool from '@/utils/tool'const storeTags = viewTagsStore()import AIModalApi from '@/api/auth/AIModalApi'// 容器尺寸和位置状态const chatContainer = ref(null)const containerWidth = ref(600)const containerHeight = ref(600)const containerTop = ref(100)const containerLeft = ref(1000)// 聊天记录固定宽度const recordsWidth = 400const minChatBoxWidth = 400// 拖拽和缩放状态const isDragging = ref(false)const dragStartPos = ref({ x: 0, y: 0 })const isResizing = ref(false)const resizeDir = ref('')const resizeStartPos = ref({ x: 0, y: 0 })const resizeStartSize = ref({ width: 0, height: 0 })const edgeSize = 5const minHeight = 500// 初始化onMounted(() => {recordShow.value = falsemessages.value = tool.data.get('deepSeekData') ?? []// 监听全局点击事件document.addEventListener('click', handleOutsideClick)document.addEventListener('mousemove', handleMouseMove)document.addEventListener('mouseup', handleMouseUp)})// 清理事件监听onUnmounted(() => {document.removeEventListener('click', handleOutsideClick)document.removeEventListener('mousemove', handleMouseMove)document.removeEventListener('mouseup', handleMouseUp)})const handleOutsideClick = (e) => {// 判断是否点击了子组件内部(包含自身)const isInsideChild = chatContainer.value?.contains(e.target)// 判断是否点击了仓库中“排除列表”的元素const isInsideExclude = storeTags.excludeElements.some((el) => {return el && el.contains(e.target)})if (!isInsideChild && !isInsideExclude) {// console.log('点击dom外部')closeChange()}}// 开始拖拽(顶部导航栏)const startDrag = (e) => {e.preventDefault()isDragging.value = truedragStartPos.value = {x: e.clientX - containerLeft.value,y: e.clientY - containerTop.value}if (chatContainer.value) {chatContainer.value.style.cursor = 'grabbing'}}// 处理鼠标移动(拖拽和缩放)const handleMouseMove = (e) => {// 处理拖拽if (isDragging.value) {let newLeft = e.clientX - dragStartPos.value.xlet newTop = e.clientY - dragStartPos.value.y// 边界检查,确保窗口不会移出屏幕const windowWidth = window.innerWidthconst windowHeight = window.innerHeightnewLeft = Math.max(0, newLeft)newLeft = Math.min(windowWidth - containerWidth.value, newLeft)newTop = Math.max(0, newTop)newTop = Math.min(windowHeight - containerHeight.value, newTop)containerLeft.value = newLeftcontainerTop.value = newTopreturn}// 处理缩放if (isResizing.value && resizeDir.value) {const dx = e.clientX - resizeStartPos.value.xconst dy = e.clientY - resizeStartPos.value.ylet newWidth = resizeStartSize.value.widthlet newHeight = resizeStartSize.value.heightlet newLeft = containerLeft.valuelet newTop = containerTop.valueswitch (resizeDir.value) {case 'right':newWidth = Math.max(minChatBoxWidth + (recordShow.value ? recordsWidth : 0), resizeStartSize.value.width + dx)breakcase 'left':newWidth = Math.max(minChatBoxWidth + (recordShow.value ? recordsWidth : 0), resizeStartSize.value.width - dx)newLeft = resizeStartPos.value.x - (newWidth - resizeStartSize.value.width)breakcase 'bottom':newHeight = Math.max(minHeight, resizeStartSize.value.height + dy)breakcase 'top':newHeight = Math.max(minHeight, resizeStartSize.value.height - dy)newTop = resizeStartPos.value.y - (newHeight - resizeStartSize.value.height)breakcase 'bottom-right':newWidth = Math.max(minChatBoxWidth + (recordShow.value ? recordsWidth : 0), resizeStartSize.value.width + dx)newHeight = Math.max(minHeight, resizeStartSize.value.height + dy)breakcase 'bottom-left':newWidth = Math.max(minChatBoxWidth + (recordShow.value ? recordsWidth : 0), resizeStartSize.value.width - dx)newHeight = Math.max(minHeight, resizeStartSize.value.height + dy)newLeft = resizeStartPos.value.x - (newWidth - resizeStartSize.value.width)breakcase 'top-right':newWidth = Math.max(minChatBoxWidth + (recordShow.value ? recordsWidth : 0), resizeStartSize.value.width + dx)newHeight = Math.max(minHeight, resizeStartSize.value.height - dy)newTop = resizeStartPos.value.y - (newHeight - resizeStartSize.value.height)breakcase 'top-left':newWidth = Math.max(minChatBoxWidth + (recordShow.value ? recordsWidth : 0), resizeStartSize.value.width - dx)newHeight = Math.max(minHeight, resizeStartSize.value.height - dy)newLeft = resizeStartPos.value.x - (newWidth - resizeStartSize.value.width)newTop = resizeStartPos.value.y - (newHeight - resizeStartSize.value.height)break}// 边界检查const windowWidth = window.innerWidthconst windowHeight = window.innerHeightnewLeft = Math.max(0, newLeft)newLeft = Math.min(windowWidth - newWidth, newLeft)newTop = Math.max(0, newTop)newTop = Math.min(windowHeight - newHeight, newTop)containerWidth.value = newWidthcontainerHeight.value = newHeightcontainerLeft.value = newLeftcontainerTop.value = newTopreturn}// 检测鼠标是否在边缘,显示相应光标if (chatContainer.value) {const rect = chatContainer.value.getBoundingClientRect()const x = e.clientXconst y = e.clientYresizeDir.value = ''chatContainer.value.style.cursor = 'default'const isLeft = x >= rect.left && x <= rect.left + edgeSizeconst isRight = x >= rect.right - edgeSize && x <= rect.rightconst isTop = y >= rect.top && y <= rect.top + edgeSizeconst isBottom = y >= rect.bottom - edgeSize && y <= rect.bottomif (isLeft && isTop) {resizeDir.value = 'top-left'chatContainer.value.style.cursor = 'nwse-resize'} else if (isRight && isTop) {resizeDir.value = 'top-right'chatContainer.value.style.cursor = 'nesw-resize'} else if (isLeft && isBottom) {resizeDir.value = 'bottom-left'chatContainer.value.style.cursor = 'nesw-resize'} else if (isRight && isBottom) {resizeDir.value = 'bottom-right'chatContainer.value.style.cursor = 'nwse-resize'} else if (isLeft) {resizeDir.value = 'left'chatContainer.value.style.cursor = 'ew-resize'} else if (isRight) {resizeDir.value = 'right'chatContainer.value.style.cursor = 'ew-resize'} else if (isTop) {resizeDir.value = 'top'chatContainer.value.style.cursor = 'ns-resize'} else if (isBottom) {resizeDir.value = 'bottom'chatContainer.value.style.cursor = 'ns-resize'}}}// 开始缩放const startResize = (e) => {if (e.target.closest('.chat-header')) returne.preventDefault()if (resizeDir.value) {isResizing.value = trueresizeStartPos.value = { x: e.clientX, y: e.clientY }resizeStartSize.value = {width: containerWidth.value,height: containerHeight.value}}}// 结束拖拽和缩放const handleMouseUp = () => {if (isDragging.value) {isDragging.value = falseif (chatContainer.value) {chatContainer.value.style.cursor = 'default'}}if (isResizing.value) {isResizing.value = false}}// 切换聊天记录显示时调整容器宽度const chatRecords = () => {recordShow.value = !recordShow.valueif (recordShow.value) {containerWidth.value = Math.max(containerWidth.value, minChatBoxWidth + recordsWidth)// 首次加载聊天记录时获取总页数并跳转到最后一页loadLastPageRecords()} else {containerWidth.value = Math.max(minChatBoxWidth, containerWidth.value - recordsWidth)}}// 加载最后一页记录const loadLastPageRecords = async () => {// 先获取总页数const res = await AIModalApi.getdeepseekLogPage({current: 1,size: size.value,searchKey: searchKey.value})// 计算最后一页页码const lastPage = res.pages > 0 ? res.pages : 1current.value = lastPagepages.value = res.pages// 加载最后一页数据recordDataApi()}// AIconst open = ref(false)const closeChange = () => {tool.data.set('deepSeekData', messages.value)storeTags.AIModalOpenChange(false)}// 监听窗口打开状态watch(() => storeTags.AIModalOpen,(newValue) => {open.value = newValueif (newValue) {containerTop.value = 100containerLeft.value = 1000containerWidth.value = 600containerHeight.value = 600}},{ immediate: true })// 消息列表数据const messages = ref([])const userInput = ref('')const isLoading = ref(false)// 发送消息const sendMessage = async () => {const content = userInput.value.trim()if (!content || isLoading.value) returnmessages.value.push({ role: 'user', content })userInput.value = ''scrollToBottom()isLoading.value = truemessages.value.push({ role: 'deepseek', content: '', loading: true })scrollToBottom()try {const response = await AIModalApi.getDeepSeekAiData({ message: content })messages.value.pop()messages.value.push({role: 'deepseek',content: response,loading: false})} catch (error) {console.error('对话失败:', error)messages.value.pop()messages.value.push({role: 'deepseek',content: '抱歉,获取回复失败,请重试',loading: false,error: true})} finally {isLoading.value = falsescrollToBottom()}}// 处理Shift+Enter换行const handleShiftEnter = () => {userInput.value += '\n'}// 滚动到底部const scrollToBottom = () => {nextTick(() => {const container = document.querySelector('.chat-messages')if (container) container.scrollTop = container.scrollHeight})}// 格式化AI内容const formatContent = (content) => {return content.replace(/\n/g, '<br>').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\*(.*?)\*/g, '<em>$1</em>')}// 聊天记录const searchShow = ref(false)const searchKey = ref('')const recordShow = ref(false)const recordList = ref([])const recordDataApi = async () => {const res = await AIModalApi.getdeepseekLogPage({current: current.value,size: size.value,searchKey: searchKey.value})recordList.value = convertToChatFormat(res.records)total.value = res.totalpages.value = res.pagesnextTick(() => {const container = document.querySelector('.chatRecords .chat-messages')if (container) {container.scrollTop = container.scrollHeight}})}// 处理返回数据const convertToChatFormat = (data) => {const chatList = []data.forEach((item) => {chatList.push({role: 'user',content: item.sentMessage})chatList.push({role: 'deepseek',content: item.returnMessage})})return chatList}// 分页const current = ref(1)const pages = ref(0)const total = ref(0)const size = ref(5)const handlePageChange = (value) => {current.value = valuerecordDataApi()}// 搜索时加载最后一页结果const onSearch = async (value) => {searchKey.value = valueloadLastPageRecords()}const searchDom = ref(null)const searchChange = () => {searchShow.value = !searchShow.valueif (searchShow.value) {searchDom.value.style.color = 'blue'} else {searchDom.value.style.color = 'gray'searchKey.value = ''// 重置搜索时也显示最后一页loadLastPageRecords()}}
</script><style scoped lang="less">.chat-container {display: flex;flex-direction: column;position: absolute;background-color: #d3d7f1d7;border: 2px solid rgba(5, 56, 131, 0.8);border-radius: 10px;padding: 10px;z-index: 1001;touch-action: none;.chat-header {width: 100%;display: flex;justify-content: space-between;padding: 10px;border-bottom: 1px solid rgba(5, 56, 131, 0.4);cursor: grab;&:active {cursor: grabbing;}.headerTitle {font-size: 16px;font-weight: 500;}.closeButton {width: 100%;font-size: 16px;cursor: pointer;}}.chatDom {display: flex;height: calc(100% - 40px);}.chatBox {height: 100%;flex: 1;display: flex;flex-direction: column;padding-right: 10px;.chat-messages {flex: 1;padding: 10px;overflow-y: auto;width: 100%;.empty-state {display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100%;color: #9ca3af;}.empty-icon {margin-bottom: 20px;}.message-item {margin-bottom: 20px;max-width: 100%;}.user-message {display: flex;justify-content: flex-end;}.ai-message {display: flex;}.ai-avatar {width: fit-content;height: fit-content;border-radius: 30%;background: #a7a7a7;display: flex;align-items: center;justify-content: center;flex-shrink: 0;}.message-bubble {padding: 10px;border-radius: 10px;line-height: 1.5;}.user-bubble {background: #6a6aec;color: white;border-bottom-right-radius: 5px;}.ai-bubble {background: white;color: #111827;border-bottom-left-radius: 5px;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);}.empty-text {font-size: 20px;color: #8888b1;}}.chat-input-area {border-top: 1px solid rgba(5, 56, 131, 0.8);background: white;border-radius: 5px;width: 100%;.message-input {height: 50px;width: 100%;padding: 10px;border-bottom: 1px solid rgb(197, 197, 218);border-radius: 1px;resize: none;outline: none;transition: border-color 0.2s;}.message-input:focus {border-bottom: 1px solid rgb(197, 197, 218);}textarea {border: none !important;background: transparent;min-height: 50px;}.operation-btn {display: flex;justify-content: space-between;margin: 5px 10px;.chart-send {width: 100px;height: fit-content;border-radius: 10px;padding: 5px;background-color: #a1a1a1;border: none;color: white;cursor: pointer;display: flex;align-items: center;justify-content: center;transition: background 0.2s;}.send-btn {width: 60px;height: fit-content;border-radius: 10px;padding: 5px;background-color: #2629e6;border: none;color: white;cursor: pointer;display: flex;align-items: center;justify-content: center;transition: background 0.2s;}.send-btn:disabled {background: #a5b4fc;cursor: not-allowed;}.send-btn:hover:not(:disabled) {background: #4f46e5;}.chart-send:hover:not(:disabled) {background: #757575;}}}}.chatRecords {width: 300px;height: 100%;border-left: 1px solid #d3d7f1d7;display: flex;flex-direction: column;flex-shrink: 0;.chat-messages {flex: 1;padding: 10px;overflow-y: auto;width: 100%;.empty-state {display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100%;color: #9ca3af;}.empty-icon {margin-bottom: 20px;}.message-item {margin-bottom: 20px;max-width: 100%;}.user-message {display: flex;justify-content: flex-end;}.ai-message {display: flex;}.ai-avatar {width: fit-content;height: fit-content;border-radius: 30%;background: #a7a7a7;display: flex;align-items: center;justify-content: center;flex-shrink: 0;}.message-bubble {padding: 10px;border-radius: 10px;line-height: 1.5;}.user-bubble {background: #6a6aec;color: white;border-bottom-right-radius: 5px;}.ai-bubble {background: white;color: #111827;border-bottom-left-radius: 5px;box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);}.empty-text {font-size: 20px;color: #8888b1;}}.papDom {justify-content: right;height: 30px;.searchDom {display: flex;justify-content: right;align-items: center;font-size: 16px;color: gray;}}.searchDom {display: flex;margin: 10px;}.searchStyle {color: blue;}}::-webkit-scrollbar-thumb {background-color: #132aad !important;border-radius: 0.02083rem;}::-webkit-scrollbar-track {background-color: #8c8787d4;border-radius: 0.02083rem;}.ai-content {white-space: pre-wrap;}}:deep(.ant-pagination.ant-pagination-simple .ant-pagination-simple-pager input) {background: rgba(255, 0, 0, 0) !important;}:deep(.ant-pagination) {color: #000 !important;}:deep(.ant-input) {color: #000;border: 1px solid rgb(141, 141, 141);}
</style>