一、项目简介
基于vue3.x+vuex+vue-router+element-plus+v3layer+v3scroll等技术构建的仿微信web桌面端聊天实战项目Vue3-Webchat。基本上实现发送消息+emoj表情、图片/视频查看、链接预览、粘贴截图/拖拽发送图片、红包/朋友圈等功能。
二、使用技术
- 编码器:Vscode
- 技术框架:Vue3.0.5+Vuex4+VueRouter@4
- UI组件库:Element-Plus (饿了么桌面端vue3组件库)
- 弹窗组件:V3Layer(基于vue3.x自定义对话框组件)
- 滚动条组件:V3Scroll(基于vue3.x自定义虚拟美化滚动条组件)
- 字体图标:阿里iconfont图标库
三、项目结构目录
◆ 一览效果
◆ vue3.x封装自定义弹窗组件
为了整体效果一致性,项目中用到的所有弹窗功能均是自定义组件v3layer来实现。
V3Layer 基于vue3.0开发的pc端自定义弹窗组件,支持拖拽(自定义拖拽区)、缩放、最大化、全屏、置顶弹框等功能。
由于之前有过一篇详细的介绍分享,感兴趣的话可以去看下哈。
https://www.cnblogs.com/xiaoyan2017/p/14221729.html
其实v3layer弹窗是在原先的vue2版本中演变而来,专门为vue3项目而开发的,并且在功能及效果上和v2版的保持一致。
vue2.x pc端自定义全局弹窗组件|vue2桌面端对话框组件
◆ vue3.x自定义美化模拟滚动条
为了使得项目中页面滚动条更加精致,这里采用了自定义模拟滚动条vscroll组件来替代原生滚动条。
V3Scroll 基于vue3.0开发的小巧模拟滚动条组件。支持自定义滚动条大小、颜色、层级及自动隐藏等功能。
并且支持实时监测DOM尺寸改变来动态更新滚动条。
https://www.cnblogs.com/xiaoyan2017/p/14242983.html
◆ vue3.x聊天主面板
项目整体分为右上按钮、侧边栏、中间区、主体内容区三个模块。
<div :class="[\'vui__wrapper\', store.state.isWinMaximize&&\'maximize\']"> <div class="vui__board flexbox"> <div class="flex1 flexbox"> <!-- 顶部按钮(最大、最小、关闭) --> <WinBar v-if="!route.meta.hideWinBar" /> <!-- 侧边栏 --> <SideBar v-if="!route.meta.hideSideBar" class="nt__sidebar flexbox flex-col" /> <!-- 中间栏 --> <Middle v-show="!route.meta.hideMiddle" /> <!-- 主内容区 --> <router-view class="nt__mainbox flex1 flexbox flex-col"></router-view> </div> </div> </div>
◆ 引入|注册公共组件
// 引入饿了么vue3组件库 import ElementPlus from \'element-plus\' import \'element-plus/lib/theme-chalk/index.css\' // 引入vue3.x弹窗组件 import V3Layer from \'../components/v3layer\' // 引入vue3.x滚动条组件 import V3Scroll from \'@components/v3scroll\' // 引入公共组件 import WinBar from \'../layouts/winbar.vue\' import SideBar from \'../layouts/sidebar\' import Middle from \'../layouts/middle\' import Utils from \'./utils\' const Plugins = app => { app.use(ElementPlus) app.use(V3Layer) app.use(V3Scroll) // 注册公共组件 app.component(\'WinBar\', WinBar) app.component(\'SideBar\', SideBar) app.component(\'Middle\', Middle) app.provide(\'utils\', Utils) }
项目中背景整体采用虚化毛玻璃效果。通过 svg filter 来实现。
<!-- //虚化背景(毛玻璃) --> <div class="vui__bgblur"> <svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="100%" height="100%" class="blur-svg" viewBox="0 0 1920 875" preserveAspectRatio="none"> <filter id="blur_mkvvpnf"><feGaussianBlur in="SourceGraphic" stdDeviation="50"></feGaussianBlur></filter> <image :xlink:href="store.state.skin" x="0" y="0" width="100%" height="100%" externalResourcesRequired="true" xmlns:xlink="http://www.w3.org/1999/xlink" style="filter:url(#blur_mkvvpnf)" preserveAspectRatio="none"></image> </svg> <div class="blur-cover"></div> </div>
◆ vue3.x表单验证/登录状态拦截
vue3中实现表单验证+60s倒计时操作。
<script> import { reactive, toRefs, inject, getCurrentInstance } from \'vue\' export default { components: {}, setup() { const { ctx } = getCurrentInstance() const v3layer = inject(\'v3layer\') const utils = inject(\'utils\') const formObj = reactive({}) const data = reactive({ vcodeText: \'获取验证码\', disabled: false, time: 0, }) const VTips = (content) => { v3layer({ content: content, layerStyle: \'background:#ff5151;color:#fff;\', time: 2 }) } const handleSubmit = () => { if(!formObj.tel){ VTips(\'手机号不能为空!\') }else if(!utils.checkTel(formObj.tel)){ VTips(\'手机号格式不正确!\') }else if(!formObj.pwd){ VTips(\'密码不能为空!\') }else if(!formObj.vcode){ VTips(\'验证码不能为空!\') }else{ ctx.$store.commit(\'SET_TOKEN\', utils.setToken()); ctx.$store.commit(\'SET_USER\', formObj.tel); // ... } } // 60s倒计时 const handleVcode = () => { if(!formObj.tel) { VTips(\'手机号不能为空!\') }else if(!utils.checkTel(formObj.tel)) { VTips(\'手机号格式不正确!\') }else { data.time = 60 data.disabled = true countDown() } } const countDown = () => { if(data.time > 0) { data.vcodeText = \'获取验证码(\'+ data.time +\')\' data.time-- setTimeout(countDown, 1000) }else{ data.vcodeText = \'获取验证码\' data.time = 0 data.disabled = false } } return { formObj, ...toRefs(data), handleSubmit, handleVcode } } } </script>
vue3路由钩子实现全局登录状态拦截判断。
import { createRouter, createWebHistory } from \'vue-router\'
import store from \'../store\'
import V3Layer from \'@components/v3layer\'
const routesLS = [
// 登录|注册
{
name: \'login\', path: \'/login\',
component: () => import(\'../views/auth/login.vue\'),
meta: { hideWinBar: true, hideSideBar: true, hideMiddle: true }
},
// ...
]
const router = createRouter({
history: createWebHistory(),
routes: routesLS,
})
// 全局钩子拦截登录状态
router.beforeEach((to, from, next) => {
const token = store.state.token
// 判断当前路由地址是否需要登录权限
if(to.meta.requireAuth) {
if(token) {
next()
}else {
// 未登录授权
V3Layer({
content: \'还未登录授权!\', position: \'top\', time: 2,
onEnd: () => {
next({ path: \'/login\' })
}
})
}
}else {
next()
}
})
◆ vue3.x聊天模块
聊天编辑器模块继续采用分离公共调用方式。支持多行文本、文字+emoj表情混排、光标处插入表情、粘贴截图发送等功能。
/** * @Desc vue3.x仿微信桌面端聊天 * @Time andy by 2021-01 * @About Q:282310962 wx:xy190310 */ <script> import { onMounted, ref, reactive, toRefs, watch, nextTick, inject } from \'vue\' import { useRoute } from \'vue-router\' import Editor from \'./editor.vue\' import SendRedPacket from \'./redPacket.vue\' import GroupSet from \'./groupInfo.vue\' // ... export default { components: { Editor, SendRedPacket, GroupSet }, setup() { const scrollRef = ref(null) const editorRef = ref(null) const route = useRoute() const v3layer = inject(\'v3layer\') const data = reactive({ editorText: \'\', showEmojView: false, isSubmitDisabled: true, // ... }) // ... // 获取群组信息 const getGroupJSON = () => { msgJSON.map((item) => { if(item.cid == route.query.id) { data.groupLs = item } }) // 定位消息到底部 nextTick(() => { imgLoaded(scrollRef) }) } // ... /** * 编辑器粘贴事件 * @param img 返回粘贴图片地址 */ const handleEditorPaste = (img) => { let msgLs = data.groupLs.msglist let len = msgLs.length // 消息队列 let arrLS = { // ... } msgLs = msgLs.concat(arrLS) data.groupLs.msglist = msgLs nextTick(() => { imgLoaded(scrollRef) }) } // 点击表情 const handleEmojClicked = (e) => { let faceimg = e.target.cloneNode(true) editorRef.value.insertHtmlAtCursor(faceimg) data.showEmojView = false } // 点击表情gif const handleEmojGifClicked = (path) => { let msgLs = data.groupLs.msglist let len = msgLs.length // 消息队列 let arrLS = { // ... } msgLs = msgLs.concat(arrLS) data.groupLs.msglist = msgLs data.showEmojView = false nextTick(() => { imgLoaded(scrollRef) }) } /* ---------- { 选择功能模块 } ---------- */ // 选择视频 const handleChooseVideo = () => { let msgLs = data.groupLs.msglist let len = msgLs.length // 消息队列 let arrLS = { // ... } let file = pickVideoRef.value.files[0] if(!file) return let size = Math.floor(file.size / 1024) if(size > 5*1024) { v3layer({content: \'请选择5MB以内的视频!\'}) return false } // 获取视频地址 let videoUrl if(window.createObjectURL != undefined) { videoUrl = window.createObjectURL(file) } else if (window.URL != undefined) { videoUrl = window.URL.createObjectURL(file) } else if (window.webkitURL != undefined) { videoUrl = window.webkitURL.createObjectURL(file) } let $video = document.createElement(\'video\') $video.src = videoUrl // 截取视频第一帧为封面 $video.addEventListener(\'loadeddata\', function() { setTimeout(() => { var canvas = document.createElement(\'canvas\') canvas.width = $video.videoWidth * .8 canvas.height = $video.videoHeight * .8 canvas.getContext(\'2d\').drawImage($video, 0, 0, canvas.width, canvas.height) arrLS.imgsrc = canvas.toDataURL(\'image/png\') arrLS.videosrc = videoUrl msgLs = msgLs.concat(arrLS) data.groupLs.msglist = msgLs nextTick(() => { imgLoaded(scrollRef) }) }, 16); }) } /* ---------- { 拖拽功能模块 } ---------- */ const handleDragEnter = (e) => { e.stopPropagation() e.preventDefault() } const handleDragOver = (e) => { e.stopPropagation() e.preventDefault() } const handleDrop = (e) => { e.stopPropagation() e.preventDefault() // console.log(e.dataTransfer) handleFileList(e.dataTransfer) } // 获取拖拽文件列表 const handleFileList = (filelist) => { let files = filelist.files if(files.length >= 2) { v3layer.message({icon: \'error\', content: \'暂时支持拖拽一张图片\', shade: true, layerStyle: {background:\'#ffefe6\',color:\'#ff3838\'}}) return false } for(let i = 0; i < files.length; i++) { if(files[i].type != \'\') { handleFileAdd(files[i]) }else { v3layer.message({icon: \'error\', content: \'目前不支持文件夹拖拽功能\', shade: true, layerStyle: {background:\'#ffefe6\',color:\'#ff3838\'}}) } } } const handleFileAdd = (file) => { let msgLs = data.groupLs.msglist let len = msgLs.length // 消息队列 let arrLS = { // ... } if(file.type.indexOf(\'image\') == -1) { v3layer.message({icon: \'error\', content: \'目前不支持非图片拖拽功能\', shade: true, layerStyle: {background:\'#ffefe6\',color:\'#ff3838\'}}) }else { let reader = new FileReader() reader.readAsDataURL(file) reader.onload = function() { let img = this.result // ... } } } /* ---------- { 其他功能模块 } ---------- */ // 提示信息 const handleTipsLayer = (e) => { let pos = [e.clientX+25, e.clientY-110] v3layer.popover({ icon: \'info\', title: \'Tips\', content: \'<div class="pb-10">编辑框支持<b class="bg-00e077 c-fff">拖拽</b>或<b class="bg-00e077 c-fff">截屏粘贴</b>发送图片!<br />支持自动<b class="bg-00e077 c-fff">链接</b>识别!</div>\', follow: pos, shade: true, opacity: .2, }) } // 红包弹窗 const handleRedpacketLayer = (item) => { data.isShowRedPacket = true data.redPacketList = item } // ... return { ...toRefs(data), scrollRef, editorRef, handleMsgClicked, handleEmojView, handleEmojTab, handleEditorClick, handleEditorFocus, handleEditorBlur, handleEditorPaste, handleEmojClicked, // ... } } } </script>
Ok,以上就是使用Vue3+ElementPlus开发网页端仿微信/QQ界面聊天的分享。