最终效果
完整代码
index.vue
<template> <div class="page"> <div class="leftBox"> <h1>访客</h1> <div class="chatBox"> <div class="chatRecordBox"> <div v-for="(item, index) in chatRecordList" :key="index"> <RightRole v-if="item.role === '访客'" :type="item.type" :content="item.content" :avatarURL="visitorAvatarURL" @openMenu="openMenu($event, index, true)" /> <LeftRole v-if="item.role === '客服'" :type="item.type" :content="item.content" :avatarURL="serviceAvatarURL" @openMenu="openMenu($event, index)" /> </div> </div> <div class="toolBox"> <ChooseEmotion @getEmo="chooseEmo($event, 'visitor')" /> <img @click="chooseImg('visitor')" class="imgIcon" src="@/assets/images/图片.svg" /> <img @click="chooseVideo('visitor')" class="videoIcon" src="@/assets/images/视频.svg" /> <img @click="chooseAudio('visitor')" class="audioIcon" src="@/assets/images/音频.svg" /> </div> <div ref="visitorSendContentBox_Ref" contenteditable="true" class="sendContentBox" ></div> <div class="btnBox"> <el-button @click="visitorSend" size="small">发送</el-button> </div> </div> </div> <div class="rightBox"> <h1>客服</h1> <div class="chatBox"> <div class="chatRecordBox"> <div v-for="(item, index) in chatRecordList" :key="index"> <LeftRole v-if="item.role === '访客'" :type="item.type" :content="item.content" :avatarURL="visitorAvatarURL" @openMenu="openMenu($event, index)" /> <RightRole v-if="item.role === '客服'" :type="item.type" :content="item.content" :avatarURL="serviceAvatarURL" @openMenu="openMenu($event, index, true)" /> </div> </div> <div class="toolBox"> <ChooseEmotion @getEmo="chooseEmo($event, 'service')" /> <img @click="chooseImg('service')" class="imgIcon" src="@/assets/images/图片.svg" /> <img @click="chooseVideo('service')" class="videoIcon" src="@/assets/images/视频.svg" /> <img @click="chooseAudio('service')" class="audioIcon" src="@/assets/images/音频.svg" /> </div> <div ref="serviceSendContentBox_Ref" contenteditable="true" class="sendContentBox" ></div> <div class="btnBox"> <el-button @click="serviceSend" size="small">发送</el-button> </div> </div> <el-dialog title="图片预览" :visible.sync="showPreviewImgWin" style="text-align: center" > <el-image style="height: 400px" :preview-src-list="[imgSrc]" :src="imgSrc" fit="contain" /> </el-dialog> <!-- 右键快捷菜单 --> <ul v-show="quickMenuVisible" :style="{ left: quickMenu_left + 'px', top: quickMenu_top + 'px' }" class="contextmenu" > <li v-show="ifCan_rollBack" @click="rollBack">撤回</li> <li @click="copy">复制</li> </ul> </div> </div> </template> <script> import LeftRole from "./components/leftRole.vue"; import RightRole from "./components/rightRole.vue"; import ChooseEmotion from "./components/chooseEmotion.vue"; import visitorAvatarURL from "@/assets/images/访客.svg"; import serviceAvatarURL from "@/assets/images/客服.svg"; export default { watch: { quickMenuVisible(value) { if (value) { document.body.addEventListener("click", this.closeMenu); } else { document.body.removeEventListener("click", this.closeMenu); } }, }, components: { LeftRole, RightRole, ChooseEmotion }, mounted() { let that = this; // 点击图片放大预览 window.addEventListener("click", function (e) { let { target } = e; let nodeName = target.nodeName; if (nodeName === "IMG") { // 获取自定义的属性 preview let preview = target.getAttribute("preview"); // 无 preview 属性的图片,不支持点击放大预览 if (!preview) { return; } that.imgSrc = target.currentSrc; that.showPreviewImgWin = true; } }); }, data() { return { ifCan_rollBack: false, index: null, quickMenu_left: 0, quickMenu_top: 0, quickMenuVisible: false, imgSrc: "", showPreviewImgWin: false, visitorAvatarURL, serviceAvatarURL, chatRecordList: [], }; }, methods: { // 撤回消息 rollBack() { this.chatRecordList.splice(this.index, 1); }, // js 点击复制到剪贴板函数 copy() { let content = this.chatRecordList[this.index].content; if (window.clipboardData) { window.clipboardData.setData("text", content); } else { (function (content) { document.oncopy = function (e) { e.clipboardData.setData("text", content); e.preventDefault(); document.oncopy = null; }; })(content); document.execCommand("Copy"); } }, // 显示右键快捷菜单 openMenu(e, index, ifCan_rollBack) { this.ifCan_rollBack = ifCan_rollBack; this.index = index; this.quickMenu_top = e.pageY; this.quickMenu_left = e.pageX; this.quickMenuVisible = true; }, // 隐藏右键快捷菜单 closeMenu() { this.quickMenuVisible = false; }, // 根据角色,确定编辑框 getSendContentBox_Ref(role) { if (role === "visitor") { return "visitorSendContentBox_Ref"; } if (role === "service") { return "serviceSendContentBox_Ref"; } }, // 在编辑框中插入内容 sendContentBox_insert(role, insertContent) { let sendContentBox_Ref = this.getSendContentBox_Ref(role); let content = JSON.parse( JSON.stringify(this.$refs[sendContentBox_Ref].innerHTML) ); let newContent = ""; // 光标在编辑框内时 if (window.getSelection().anchorNode) { // 获取光标在编辑框中的下标 let startIndex = window.getSelection().anchorOffset; let ednIndex = window.getSelection().focusOffset; newContent = content.substring(0, startIndex) + insertContent + content.substring(ednIndex); } else { // 光标不在编辑框内时 newContent = content + insertContent; } this.$refs[sendContentBox_Ref].innerHTML = newContent; }, chooseEmo(emo, role) { this.sendContentBox_insert(role, emo); }, // 访客发送消息 visitorSend() { let content = this.$refs.visitorSendContentBox_Ref.innerHTML; if (!content) { this.$message({ message: "请输入发送内容!", type: "warning", }); return; } this.chatRecordList.push({ role: "访客", content: content, type: "text", }); // 发送后,清空发送框的内容 this.$refs.visitorSendContentBox_Ref.innerHTML = ""; }, // 客服发送消息 serviceSend() { let content = this.$refs.serviceSendContentBox_Ref.innerHTML; if (!content) { this.$message({ message: "请输入发送内容!", type: "warning", }); return; } this.chatRecordList.push({ role: "客服", content: content, type: "text", }); // 发送后,清空发送框的内容 this.$refs.serviceSendContentBox_Ref.innerHTML = ""; }, // 选择图片 chooseImg(role) { let that = this; let input = document.createElement("input"); input.setAttribute("type", "file"); // 支持多选 input.setAttribute("multiple", "multiple"); input.accept = "image/*"; input.addEventListener("change", (e) => { let file = e.path[0].files[0]; // 浏览器兼容性处理(有的浏览器仅存在 Window.URL) const windowURL = window.URL || window.webkitURL; // createObjectURL 函数会根据传入的参数创建一个指向该参数对象的URL let filePath = windowURL.createObjectURL(file); let tmp_imgDom = document.createElement("img"); tmp_imgDom.setAttribute("src", filePath); tmp_imgDom.setAttribute("height", 30); tmp_imgDom.setAttribute("preview", true); tmp_imgDom.style.cursor = "pointer"; let tmp_divDom = document.createElement("div"); tmp_divDom.appendChild(tmp_imgDom); that.chatRecordList.push({ role: role === "visitor" ? "访客" : "客服", content: tmp_divDom.innerHTML, type: "img", }); }); input.click(); }, // 选择视频 chooseVideo(role) { let that = this; let input = document.createElement("input"); input.setAttribute("type", "file"); // 支持多选 input.setAttribute("multiple", "multiple"); input.accept = "video/*"; input.addEventListener("change", (e) => { let file = e.path[0].files[0]; // 浏览器兼容性处理(有的浏览器仅存在 Window.URL) const windowURL = window.URL || window.webkitURL; // createObjectURL 函数会根据传入的参数创建一个指向该参数对象的URL let filePath = windowURL.createObjectURL(file); let tmp_videoDom = document.createElement("video"); tmp_videoDom.setAttribute("src", filePath); tmp_videoDom.setAttribute("height", 100); tmp_videoDom.setAttribute("controls", "controls"); tmp_videoDom.style.cursor = "pointer"; let tmp_divDom = document.createElement("div"); tmp_divDom.appendChild(tmp_videoDom); that.chatRecordList.push({ role: role === "visitor" ? "访客" : "客服", content: tmp_divDom.innerHTML, type: "video", }); }); input.click(); }, // 选择音频 chooseAudio(role) { let that = this; let input = document.createElement("input"); input.setAttribute("type", "file"); // 支持多选 input.setAttribute("multiple", "multiple"); input.accept = "audio/*"; input.addEventListener("change", (e) => { let file = e.path[0].files[0]; // 浏览器兼容性处理(有的浏览器仅存在 Window.URL) const windowURL = window.URL || window.webkitURL; // createObjectURL 函数会根据传入的参数创建一个指向该参数对象的URL let filePath = windowURL.createObjectURL(file); let tmp_audioDom = document.createElement("audio"); tmp_audioDom.setAttribute("src", filePath); tmp_audioDom.setAttribute("height", 30); tmp_audioDom.setAttribute("controls", "controls"); tmp_audioDom.style.cursor = "pointer"; let tmp_divDom = document.createElement("div"); tmp_divDom.appendChild(tmp_audioDom); that.chatRecordList.push({ role: role === "visitor" ? "访客" : "客服", content: tmp_divDom.innerHTML, type: "audio", }); }); input.click(); }, }, }; </script> <style scoped> .page { display: flex; justify-content: space-around; } .chatBox { width: 400px; padding: 10px; background: #409eff; border-radius: 10px; } .chatRecordBox { padding: 10px; height: 400px; border-radius: 10px; background: white; overflow: auto; } .sendContentBox { height: 100px; padding: 10px; background: white; overflow: auto; border: 1px solid rgb(195, 187, 187); } .btnBox { padding-top: 10px; text-align: right; } h1 { line-height: 40px; font-weight: bold; } .toolBox { padding: 4px; background: white; margin-top: 10px; display: flex; align-items: center; } img { cursor: pointer !important; display: inline-block; } .imgIcon { margin-left: 4px; height: 20px; cursor: pointer; } .videoIcon { width: 22px; cursor: pointer; margin-left: 4px; } .audioIcon { width: 18px; cursor: pointer; margin-left: 4px; border-radius: 4px; } /* 右键快捷菜单的样式 */ .contextmenu { margin: 0; background: #fff; z-index: 3000; position: absolute; list-style-type: none; padding: 5px 0; border-radius: 4px; font-size: 12px; font-weight: 400; color: #333; box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3); } .contextmenu li { margin: 0; padding: 7px 16px; cursor: pointer; } .contextmenu li:hover { background: #eee; } </style>
components/leftRole.vue
<template> <div class="leftRoleBox"> <div> <img class="avatar" :src="avatarURL" /> </div> <!-- 左侧的聊天气泡尖角 --> <div v-if="type === 'text'" class="triangle-top-left"> <div class="triangle-top-left2"></div> </div> <!-- 聊天气泡 --> <div @contextmenu.prevent="openMenu($event)" v-html="content" :class="type === 'text' ? 'bubble-left' : 'multimediaBox'" ></div> </div> </template> <script> export default { props: { type: String, content: String, avatarURL: String, }, methods: { openMenu(e) { this.$emit("openMenu", e); }, }, }; </script> <style scoped> .leftRoleBox { display: flex; align-items: flex-start; } .avatar { width: 30px; } .triangle-top-left { position: relative; border-top: 4px solid #409eff; border-left: 4px solid transparent; margin-top: 20px; margin-left: 2px; } .triangle-top-left2 { top: -2px; left: 0px; position: absolute; border-top: 4px solid #ffffff; border-left: 4px solid transparent; } .bubble-left { text-align: justify; border-radius: 4px; padding: 4px; border: 2px solid #409eff; font-size: 12px; line-height: 16px; margin-top: 10px; } .multimediaBox { padding: 10px; } </style>
components/rightRole.vue
<template> <div class="rightRoleBox"> <div> <img class="avatar" :src="avatarURL" /> </div> <!-- 左侧的聊天气泡尖角 --> <div v-if="type === 'text'" class="triangle-top-right"> <div class="triangle-top-right2"></div> </div> <!-- 聊天气泡 --> <div @contextmenu.prevent="openMenu($event)" v-html="content" :class="type === 'text' ? 'bubble-right' : 'multimediaBox'" ></div> </div> </template> <script> export default { props: { type: String, content: String, avatarURL: String, }, methods: { openMenu(e) { this.$emit("openMenu", e); }, }, }; </script> <style scoped> .rightRoleBox { display: flex; align-items: flex-start; flex-direction: row-reverse; } .avatar { width: 30px; } .triangle-top-right { position: relative; border-top: 4px solid #409eff; border-right: 4px solid transparent; margin-top: 20px; margin-right: 2px; } .triangle-top-right2 { top: -2px; right: 0px; position: absolute; border-top: 4px solid #ffffff; border-right: 4px solid transparent; } .bubble-right { text-align: justify; border-radius: 4px; padding: 4px; border: 2px solid #409eff; font-size: 12px; line-height: 16px; margin-top: 10px; } .multimediaBox { padding: 10px; } </style>
components/chooseEmotion.vue
<template> <div class="chatIcon"> <el-popover placement="top-start" width="400" trigger="hover"> <div class="emotionList"> <a href="javascript:void(0);" @click="getEmo(index)" v-for="(item, index) in faceList" :key="index" class="emotionItem" >{{ item }}</a > </div> <img height="20" slot="reference" src="@/assets/images/表情.svg" /> </el-popover> </div> </template> <script> import emojiData from "@/assets/data/emoji.json"; export default { mounted() { for (let i in emojiData) { this.faceList.push(emojiData[i].char); } }, data() { return { faceList: [], }; }, methods: { getEmo(index) { this.$emit("getEmo", this.faceList[index]); }, }, }; </script> <style scoped> .chatIcon { font-size: 25px; } .emotionList { display: flex; flex-wrap: wrap; padding: 5px; } .emotionItem { width: 10%; font-size: 20px; text-align: center; } /*包含以下四种的链接*/ .emotionItem { text-decoration: none; } /*正常的未被访问过的链接*/ .emotionItem:link { text-decoration: none; } /*已经访问过的链接*/ .emotionItem:visited { text-decoration: none; } /*鼠标划过(停留)的链接*/ .emotionItem:hover { text-decoration: none; } /* 正在点击的链接*/ .emotionItem:active { text-decoration: none; } </style>
src\assets\data\emoji.json
[ { "codes": "1F600", "char": "😀", "name": "grinning face" }, { "codes": "1F603", "char": "😃", "name": "grinning face with big eyes" }, { "codes": "1F604", "char": "😄", "name": "grinning face with smiling eyes" }, { "codes": "1F601", "char": "😁", "name": "beaming face with smiling eyes" }, { "codes": "1F606", "char": "😆", "name": "grinning squinting face" }, { "codes": "1F605", "char": "😅", "name": "grinning face with sweat" }, { "codes": "1F923", "char": "🤣", "name": "rolling on the floor laughing" }, { "codes": "1F602", "char": "😂", "name": "face with tears of joy" }, { "codes": "1F642", "char": "🙂", "name": "slightly smiling face" }, { "codes": "1F643", "char": "🙃", "name": "upside-down face" }, { "codes": "1F609", "char": "😉", "name": "winking face" }, { "codes": "1F60A", "char": "😊", "name": "smiling face with smiling eyes" }, { "codes": "1F607", "char": "😇", "name": "smiling face with halo" }, { "codes": "1F970", "char": "🥰", "name": "smiling face with hearts" }, { "codes": "1F60D", "char": "😍", "name": "smiling face with heart-eyes" }, { "codes": "1F929", "char": "🤩", "name": "star-struck" }, { "codes": "1F618", "char": "😘", "name": "face blowing a kiss" }, { "codes": "1F617", "char": "😗", "name": "kissing face" }, { "codes": "1F61A", "char": "😚", "name": "kissing face with closed eyes" }, { "codes": "1F619", "char": "😙", "name": "kissing face with smiling eyes" }, { "codes": "1F44B", "char": "👋", "name": "waving hand" }, { "codes": "1F91A", "char": "🤚", "name": "raised back of hand" }, { "codes": "1F590", "char": "🖐", "name": "hand with fingers splayed" }, { "codes": "270B", "char": "✋", "name": "raised hand" }, { "codes": "1F596", "char": "🖖", "name": "vulcan salute" }, { "codes": "1F44C", "char": "👌", "name": "OK hand" }, { "codes": "1F90F", "char": "🤏", "name": "pinching hand" }, { "codes": "270C", "char": "✌", "name": "victory hand" }, { "codes": "1F91E", "char": "🤞", "name": "crossed fingers" }, { "codes": "1F91F", "char": "🤟", "name": "love-you gesture" }, { "codes": "1F918", "char": "🤘", "name": "sign of the horns" }, { "codes": "1F919", "char": "🤙", "name": "call me hand" }, { "codes": "1F448", "char": "👈", "name": "backhand index pointing left" }, { "codes": "1F449", "char": "👉", "name": "backhand index pointing right" }, { "codes": "1F446", "char": "👆", "name": "backhand index pointing up" }, { "codes": "1F595", "char": "🖕", "name": "middle finger" }, { "codes": "1F447", "char": "👇", "name": "backhand index pointing down" }, { "codes": "261D FE0F", "char": "☝️", "name": "index pointing up" }, { "codes": "1F44D", "char": "👍", "name": "thumbs up" }, { "codes": "1F44E", "char": "👎", "name": "thumbs down" }, { "codes": "270A", "char": "✊", "name": "raised fist" }, { "codes": "1F44A", "char": "👊", "name": "oncoming fist" }, { "codes": "1F91B", "char": "🤛", "name": "left-facing fist" }, { "codes": "1F91C", "char": "🤜", "name": "right-facing fist" } ]
配套图片素材下载
链接:https://pan.baidu.com/s/170pb-MJlMxG2nRj_3Y2VFw?pwd=oknr
提取码:oknr