最近用vue3 + js 写了聊天机器人页面 总结了几个问题
实现的功能 : 发送消息后会显示时间、可复制、可删除 ;
输入框内容太多时会固定在一个高度内 ,有滚动条方便编辑查看 ;
输入框一键清空;
发送多条信息后 , 视角会自动跳转到最后一条信息 ,不必再手动滑动
最终效果如下

----------------------------------------------------------------------------------------------
问题1 : 输入框内容太多时 , 没有固定一个高度 , 还会遮挡已有的信息

| <div class="chat-input"> |
| <el-input |
| v-model="input" |
| @keyup.enter.prevent="sendMessage" |
| placeholder="试着输入点什么..." |
| class="input" |
| type="textarea" |
| :rows="3" |
| autosize |
| :style="{ maxHeight: '100px', overflowY: 'auto' }" //加上这部分可解决问题 |
| > |
| </el-input> |
| |
| <div class="clear" @click="clearInput"> |
| <img src="../../assets/image/清空.png" alt="" /> |
| <div class="text">清空输入框</div> |
| </div> |
| |
| <el-button |
| class="button" |
| type="primary" |
| @click="sendMessage" |
| :icon="Position" |
| >发送</el-button |
| > |
| </div> |
复制
问题2 : 回车键有抖动效果

| <div class="chat-input"> |
| <el-input |
| v-model="input" |
| @keyup.enter.prevent="sendMessage" //改为@keydown,@keyup和enter可能会冲突 |
| placeholder="试着输入点什么..." |
| class="input" |
| type="textarea" |
| :rows="3" |
| autosize |
| :style="{ maxHeight: '100px', overflowY: 'auto' }" |
| > |
| </el-input> |
| |
| <div class="clear" @click="clearInput"> |
| <img src="../../assets/image/清空.png" alt="" /> |
| <div class="text">清空输入框</div> |
| </div> |
| |
| <el-button |
| class="button" |
| type="primary" |
| @click="sendMessage" |
| :icon="Position" |
| >发送</el-button |
| > |
| </div> |
复制
问题3 : 发送消息后不能跳到指定位置

| <el-scrollbar height="660px"> |
| <div class="chat"> |
| <div class="chat-messages"> |
| <div |
| v-for="(item, index) in messages" |
| :key="item.id" |
| class="message" |
| > |
| <div class="content">{{ item.text }}</div> |
| <div class="date">{{ formatTimestamp(item.timestamp) }}</div> |
| <div class="delate" @click="delateMsg(index)"> |
| <img src="../../assets/image/删除.png" alt="" /> |
| <div class="text">删除</div> |
| </div> |
| <div class="copy" @click="copyMsg(index)"> |
| <img src="../../assets/image/复制.png" alt="" /> |
| <div class="text">复制</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </el-scrollbar> |
| <div class="chat-input"> |
| <el-input |
| v-model="input" |
| @keyup.enter.prevent="sendMessage" |
| placeholder="试着输入点什么..." |
| class="input" |
| type="textarea" |
| :rows="3" |
| autosize |
| :style="{ maxHeight: '100px', overflowY: 'auto' }" |
| > |
| </el-input> |
| |
| <div class="clear" @click="clearInput"> |
| <img src="../../assets/image/清空.png" alt="" /> |
| <div class="text">清空输入框</div> |
| </div> |
| |
| <el-button |
| class="button" |
| type="primary" |
| @click="sendMessage" |
| :icon="Position" |
| >发送</el-button |
| > |
| </div> |
| |
| |
| <script setup> |
| |
| import { ref, nextTick } from 'vue' |
| const messages = ref([]) |
| const input = ref('') |
| const text = ref('') |
| |
| |
| const sendMessage = async () => { //声明异步函数,为方便下面使用await nextTick() |
| if (input.value.trim() !== '') { |
| messages.value.push({ |
| id: Date.now(), |
| text: input.value, |
| timestamp: new Date() |
| }) |
| |
| input.value = '' |
| |
| await nextTick() // 等待DOM更新完成 |
| |
| const messageElements = document.getElementsByClassName('message') |
| const lastMessageElement = messageElements[messageElements.length - 1] // 找到最后一个信息类名 |
| lastMessageElement.scrollIntoView({ behavior: 'smooth', block: 'end' }) // 平滑的跳转到最后一个信息 |
| } |
| } |
复制
完整代码如下
| <template> |
| <div class="deploy"> |
| <div class="cardTop">开始聊天吧</div> |
| <el-card class="elCard"> |
| <div class="left"> |
| <ul class="leftUl"> |
| <li class="leftLi"> |
| <div class="circle"> |
| <img src="../../assets/image/圆圈.svg" alt="" /> |
| </div> |
| </li> |
| <li class="leftLi2"> |
| <div class="circle"> |
| <img src="../../assets/image/圆圈.svg" alt="" /> |
| </div> |
| </li> |
| </ul> |
| <div class="robot"> |
| <img src="../../assets/image/机器人.png" alt="" /> |
| <img src="../../assets/image/信息2.png" alt="" /> |
| </div> |
| </div> |
| <div class="right"> |
| <el-scrollbar height="660px"> |
| <div class="chat"> |
| <div class="chat-messages"> |
| <div |
| v-for="(item, index) in messages" |
| :key="item.id" |
| class="message" |
| > |
| <div class="content">{{ item.text }}</div> |
| <div class="date">{{ formatTimestamp(item.timestamp) }}</div> |
| <div class="delate" @click="delateMsg(index)"> |
| <img src="../../assets/image/删除.png" alt="" /> |
| <div class="text">删除</div> |
| </div> |
| <div class="copy" @click="copyMsg(index)"> |
| <img src="../../assets/image/复制.png" alt="" /> |
| <div class="text">复制</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </el-scrollbar> |
| <div class="chat-input"> |
| <el-input |
| v-model="input" |
| @keydown.enter.prevent="sendMessage" |
| placeholder="试着输入点什么..." |
| class="input" |
| type="textarea" |
| :rows="3" |
| autosize |
| :style="{ maxHeight: '100px', overflowY: 'auto' }" |
| > |
| </el-input> |
| |
| <div class="clear" @click="clearInput"> |
| <img src="../../assets/image/清空.png" alt="" /> |
| <div class="text">清空输入框</div> |
| </div> |
| |
| <el-button |
| class="button" |
| type="primary" |
| @click="sendMessage" |
| :icon="Position" |
| >发送</el-button |
| > |
| </div> |
| </div> |
| </el-card> |
| </div> |
| </template> |
| |
| <script setup> |
| import { |
| ElButton, |
| ElScrollbar, |
| ElInput, |
| ElCard, |
| ElMessageBox, |
| ElMessage |
| } from 'element-plus' |
| import { Position } from '@element-plus/icons-vue' |
| import { ref, nextTick } from 'vue' |
| const messages = ref([]) |
| const input = ref('') |
| const text = ref('') |
| |
| const sendMessage = async () => { |
| if (input.value.trim() !== '') { |
| messages.value.push({ |
| id: Date.now(), |
| text: input.value, |
| timestamp: new Date() |
| }) |
| |
| input.value = '' |
| |
| await nextTick() |
| |
| const messageElements = document.getElementsByClassName('message') |
| const lastMessageElement = messageElements[messageElements.length - 1] |
| lastMessageElement.scrollIntoView({ behavior: 'smooth', block: 'end' }) |
| } |
| } |
| |
| const clearInput = () => { |
| input.value = '' |
| } |
| |
| const formatTimestamp = timestamp => { |
| const options = { |
| year: 'numeric', |
| month: 'numeric', |
| day: 'numeric', |
| hour: 'numeric', |
| minute: 'numeric', |
| second: 'numeric' |
| } |
| return new Intl.DateTimeFormat('default', options).format(timestamp) |
| } |
| |
| const copyMsg = index => { |
| const text = messages.value[index].text |
| const textarea = document.createElement('textarea') |
| textarea.value = text |
| document.body.appendChild(textarea) |
| textarea.select() |
| document.execCommand('copy') |
| document.body.removeChild(textarea) |
| ElMessage({ |
| type: 'success', |
| message: '复制成功' |
| }) |
| } |
| |
| const delateMsg = index => { |
| ElMessageBox.confirm(`是否删除此数据吗?`, '警告', { |
| confirmButtonText: '确定', |
| cancelButtonText: '取消', |
| type: 'warning' |
| }) |
| .then(() => { |
| messages.value.splice(index, 1) |
| ElMessage({ |
| type: 'success', |
| message: '删除成功' |
| }) |
| }) |
| .catch(() => {}) |
| } |
| </script> |
| |
| <style lang="less" scoped> |
| .deploy { |
| padding: 20px 60px 0 60px; |
| |
| .cardTop { |
| margin-bottom: 20px; |
| color: #27264d; |
| font-size: 32px; |
| opacity: 0.85; |
| display: flex; |
| } |
| .elCard { |
| height: 800px; |
| .left { |
| width: 300px; |
| margin-right: 70px; |
| flex: 0; |
| display: flex; |
| .leftUl { |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .leftLi { |
| display: flex; |
| .circle { |
| position: relative; |
| } |
| .circle::after { |
| content: ''; |
| position: absolute; |
| top: 60%; |
| left: 50%; |
| transform: translate(-50%, 0); |
| width: 2px; |
| height: 740px; |
| background-color: #75b7ff; |
| } |
| } |
| .leftLi2 { |
| display: flex; |
| margin-top: 720px; |
| } |
| } |
| .robot { |
| display: flex; |
| flex-direction: column; |
| justify-content: space-between; |
| img { |
| width: 40px; |
| height: 40px; |
| } |
| } |
| } |
| .right { |
| flex: 1; |
| padding: 0 30px; |
| border: 2px solid #ccc; |
| border-radius: 7px; |
| position: relative; |
| .chat { |
| height: 660px; |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| |
| .chat-messages { |
| flex: 1; |
| margin-right: 200px; |
| |
| .message { |
| display: flex; |
| flex-direction: column; |
| margin-top: 50px; |
| position: relative; |
| |
| .content { |
| height: 200px; |
| background: #f4f6f8; |
| display: flex; |
| padding: 2px 0 0 5px; |
| border: 1px solid #ccc; |
| word-wrap: break-word; |
| } |
| .date { |
| position: absolute; |
| align-self: flex-start; |
| top: -20px; |
| color: #ccc; |
| } |
| .delate { |
| position: absolute; |
| align-self: flex-end; |
| bottom: 0px; |
| right: -60px; |
| display: flex; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .text { |
| color: #1296db; |
| opacity: 0; |
| padding-left: 5px; |
| } |
| } |
| .delate:hover { |
| cursor: pointer; |
| } |
| .delate:hover .text { |
| opacity: 1; |
| } |
| .copy { |
| position: absolute; |
| align-self: flex-end; |
| bottom: 30px; |
| right: -60px; |
| display: flex; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .text { |
| color: #1296db; |
| opacity: 0; |
| padding-left: 5px; |
| } |
| } |
| .copy:hover { |
| cursor: pointer; |
| } |
| .copy:hover .text { |
| opacity: 1; |
| } |
| } |
| } |
| } |
| .chat-input { |
| display: flex; |
| align-items: center; |
| z-index: 1000; |
| position: absolute; |
| bottom: 10px; |
| width: 95%; |
| .input { |
| margin-right: 10px; |
| } |
| .clear { |
| width: 20px; |
| height: 50px; |
| display: flex; |
| margin-left: 5px; |
| justify-content: center; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| |
| .text { |
| font-size: 12px; |
| color: #1296db; |
| margin-left: 5px; |
| opacity: 0; |
| } |
| } |
| |
| .clear:hover { |
| cursor: pointer; |
| width: 100px; |
| } |
| |
| .clear:hover .text { |
| opacity: 1; |
| margin-right: 15px; |
| } |
| |
| .button { |
| padding: 15px; |
| } |
| } |
| } |
| } |
| |
| :deep(.el-card__body) { |
| display: flex; |
| width: 100%; |
| } |
| :deep(.el-input__inner) { |
| white-space: pre-wrap; |
| word-break: break-all; |
| height: 30px; |
| } |
| } |
| </style> |
复制
拓 : 上述聊天机器人不够完美
完美效果如下 : 自己发送数据后 , 系统有返回 , 还可以删除系统或自己问题

完美版的代码如下
| <template> |
| <div class="right"> |
| <el-scrollbar height="400px"> |
| |
| <div class="allReply" v-for="(item, index) in messages" :key="item.id"> |
| |
| <div class="chat2" v-if="!item.deleted2"> |
| <div class="chat-messages2"> |
| <div class="message"> |
| <div class="content">{{ item.text }}</div> |
| <div class="date"> |
| {{ formatTimestamp(item.timestamp) }} |
| </div> |
| <div class="delate2" @click="delateMsg(index, 'self')"> |
| <div class="text">删除</div> |
| |
| <img src="../../assets/image/删除.png" alt="" /> |
| </div> |
| <div class="copy2" @click="copyMsg(index)"> |
| <div class="text">复制</div> |
| <img src="../../assets/image/复制.png" alt="" /> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="chat" v-if="!item.deleted"> |
| <img |
| src="../../assets/image/gpt.png" |
| alt="" |
| srcset="" |
| class="chatgptLogo" |
| v-if="isGptShow" |
| /> |
| <div class="chat-messages"> |
| <div class="message"> |
| <div class="content">{{ item.textSystem }}</div> |
| <div class="date"> |
| {{ formatTimestamp(item.timestamp) }} |
| </div> |
| <div class="delate" @click="delateMsg(index, 'system')"> |
| <img src="../../assets/image/删除.png" alt="" /> |
| <div class="text">删除</div> |
| </div> |
| <div class="copy" @click="copyMsg(index)"> |
| <img src="../../assets/image/复制.png" alt="" /> |
| <div class="text">复制</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </el-scrollbar> |
| |
| <div class="chat-input"> |
| <el-input |
| v-model="input" |
| @keydown.enter.prevent="sendMessage" |
| placeholder="试着输入点什么..." |
| class="input" |
| type="textarea" |
| :rows="3" |
| autosize |
| :style="{ maxHeight: '90px', overflowY: 'auto' }" |
| > |
| </el-input> |
| |
| <div class="clear" @click="clearInput"> |
| <img src="../../assets/image/清空.png" alt="" /> |
| <div class="text">清空输入框</div> |
| </div> |
| |
| <el-button |
| class="button" |
| type="primary" |
| @click="sendMessage" |
| :icon="Position" |
| >发送</el-button |
| > |
| </div> |
| </div> |
| </template> |
| |
| <script setup> |
| import { |
| ElButton, |
| ElScrollbar, |
| ElInput, |
| ElMessageBox, |
| ElMessage |
| } from 'element-plus' |
| import { Position } from '@element-plus/icons-vue' |
| import { ref, nextTick } from 'vue' |
| |
| const messages = ref([]) |
| |
| const input = ref('') |
| const text = ref('') |
| const isGptShow = ref(false) |
| |
| const sendMessage = async () => { |
| isGptShow.value = true |
| if (input.value.trim() !== '') { |
| const newMessage = { |
| id: Date.now(), |
| text: input.value, |
| textSystem: '我是系统回复...', |
| timestamp: new Date() |
| } |
| |
| messages.value.push(newMessage) |
| |
| input.value = '' |
| |
| await nextTick() |
| |
| const messageElements = document.getElementsByClassName('message') |
| const lastMessageElement = messageElements[messageElements.length - 1] |
| lastMessageElement.scrollIntoView({ behavior: 'smooth', block: 'end' }) |
| } |
| } |
| |
| const clearInput = () => { |
| input.value = '' |
| } |
| |
| const formatTimestamp = timestamp => { |
| const options = { |
| year: 'numeric', |
| month: 'numeric', |
| day: 'numeric', |
| hour: 'numeric', |
| minute: 'numeric', |
| second: 'numeric' |
| } |
| return new Intl.DateTimeFormat('default', options).format(timestamp) |
| } |
| |
| const copyMsg = index => { |
| const text = messages1.value[index].text |
| const textarea = document.createElement('textarea') |
| textarea.value = text |
| document.body.appendChild(textarea) |
| textarea.select() |
| document.execCommand('copy') |
| document.body.removeChild(textarea) |
| ElMessage({ |
| type: 'success', |
| message: '复制成功' |
| }) |
| } |
| |
| const delateMsg = (index, type) => { |
| ElMessageBox.confirm(`是否删除此数据吗?`, '警告', { |
| confirmButtonText: '确定', |
| cancelButtonText: '取消', |
| type: 'warning' |
| }) |
| .then(() => { |
| |
| |
| if (type === 'system') { |
| const message = messages.value[index] |
| delete message.textSystem |
| message.deleted = true |
| |
| } else if (type === 'self') { |
| const message = messages.value[index] |
| delete message.text |
| message.deleted2 = true |
| } |
| |
| ElMessage({ |
| type: 'success', |
| message: '删除成功' |
| }) |
| }) |
| .catch(() => {}) |
| } |
| </script> |
| |
| <style lang="less" scoped> |
| .cardTop { |
| margin-bottom: 20px; |
| color: #27264d; |
| font-size: 32px; |
| opacity: 0.85; |
| display: flex; |
| } |
| .elCard { |
| height: 800px; |
| .right { |
| flex: 1; |
| padding: 0 30px; |
| position: relative; |
| height: 500px; |
| |
| // 公共区域 |
| .message { |
| display: flex; |
| flex-direction: column; |
| margin-top: 50px; |
| position: relative; |
| |
| .content { |
| height: 200px; |
| background: #f4f6f8; |
| display: flex; |
| padding: 7px 0 0 10px; |
| border: 1px solid #ccc; |
| border-radius: 7px; |
| word-wrap: break-word; |
| flex-wrap: wrap; |
| } |
| .date { |
| position: absolute; |
| align-self: flex-start; |
| bottom: -20px; |
| right: 0; |
| color: #ccc; |
| font-size: 14px; |
| } |
| .delate { |
| position: absolute; |
| align-self: flex-end; |
| bottom: 0px; |
| right: -60px; |
| display: flex; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .text { |
| color: #1296db; |
| opacity: 0; |
| padding-left: 5px; |
| } |
| } |
| .delate:hover { |
| cursor: pointer; |
| } |
| .delate:hover .text { |
| opacity: 1; |
| } |
| .copy { |
| position: absolute; |
| align-self: flex-end; |
| bottom: 30px; |
| right: -60px; |
| display: flex; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .text { |
| color: #1296db; |
| opacity: 0; |
| padding-left: 5px; |
| } |
| } |
| .copy:hover { |
| cursor: pointer; |
| } |
| .copy:hover .text { |
| opacity: 1; |
| } |
| } |
| |
| // 左侧系统的输出 |
| .chat { |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| position: relative; |
| .chatgptLogo { |
| width: 20px; |
| height: 20px; |
| position: absolute; |
| top: 30px; |
| } |
| |
| .chat-messages { |
| flex: 1; |
| margin-right: 200px; |
| height: 400px; |
| } |
| } |
| |
| // 右侧自己的输出 |
| .chat2 { |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| |
| .chat-messages2 { |
| flex: 1; |
| margin-left: 200px; |
| height: 400px; |
| |
| .delate2 { |
| position: absolute; |
| align-self: flex-end; |
| bottom: 0px; |
| left: -60px; |
| display: flex; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .text { |
| color: #1296db; |
| opacity: 0; |
| padding-left: 5px; |
| } |
| } |
| .delate2:hover { |
| cursor: pointer; |
| } |
| .delate2:hover .text { |
| opacity: 1; |
| } |
| .copy2 { |
| position: absolute; |
| align-self: flex-end; |
| bottom: 30px; |
| left: -60px; |
| display: flex; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| .text { |
| color: #1296db; |
| opacity: 0; |
| padding-left: 5px; |
| } |
| } |
| .copy2:hover { |
| cursor: pointer; |
| } |
| .copy2:hover .text { |
| opacity: 1; |
| } |
| } |
| } |
| |
| // 输入框区域 |
| .chat-input { |
| display: flex; |
| align-items: center; |
| z-index: 1000; |
| position: absolute; |
| bottom: 10px; |
| width: 95%; |
| .input { |
| margin-right: 10px; |
| // border-top: 1px solid #ccc; |
| } |
| .clear { |
| width: 20px; |
| height: 50px; |
| display: flex; |
| margin-left: 5px; |
| justify-content: center; |
| align-items: center; |
| img { |
| width: 20px; |
| height: 20px; |
| } |
| |
| .text { |
| font-size: 12px; |
| color: #1296db; |
| margin-left: 5px; |
| opacity: 0; |
| } |
| } |
| |
| .clear:hover { |
| cursor: pointer; |
| width: 100px; |
| } |
| |
| .clear:hover .text { |
| opacity: 1; |
| margin-right: 15px; |
| } |
| |
| .button { |
| padding: 15px; |
| } |
| } |
| } |
| :deep(.el-card__body) { |
| display: flex; |
| width: 100%; |
| } |
| :deep(.el-input__inner) { |
| white-space: pre-wrap; |
| word-break: break-all; |
| height: 27px; |
| } |
| } |
| </style> |
复制