记一次uniapp实现简易版在线聊天
近期在实现uniapp各平台应用 ,其中有一个功能是实现简单的聊天界面功能,虽然听起来是一个很简单的功能,但是在开发的时候会遇到各种问题,经过上网查找解决了自己的问题,下面我记录一下。 废话不多说,直接上效果图,详细代码
chat.vue
<template>
<view class="setting">
<view class="kefu-box">
<!-- 聊天内容 -->
<scroll-view :scroll-y="true" id="scroll-view-content" class="scroll-h"
:class="active ? 'scroll-h-icon' : ''" :show-scrollbar="true"
:scroll-with-animation="true" :scroll-anchoring="true" refresher-background="#f5f5f5" :refresher-triggered="refreshTriggered" @scrolltolower="onRefresh" :scroll-into-view="'chat-item-'+scrollMsgIdx">
<view v-for="(item, index) in chatList" :key="index" class="content"
:style="{ flexDirection: item.isMe ? 'row-reverse' : '' }" :id="'chat-item-'+index">
<!-- 头像 -->
<image :src='item.avatar || defaultAvatar' mode="aspectFill" class="avatar-image">
</image>
<!-- 用户名称、消息时间 -->
<view class="user-chat-info">
<view class="user-info" :class="item.isMe ? 'order-2' : ''" :style="{ flexDirection: item.isMe ? 'row-reverse' : '' }">
<view class="user-chat-name">
{{ item.custName }}
</view>
<view class="online-time">
{{ item.createTimeStr || '' }}
</view>
</view>
<!-- 聊天消息 -->
<view class="content-text" :style="{ textAlign: item.isMe ? 'right' : 'left' }">
<view class="text-span">
<u-parse :content="item.message"></u-parse>
</view>
</view>
</view>
</view>
<view class="load-more" v-if="page <= totalPage" @click="onRefresh">查看更多消息</view>
</scroll-view>
</view>
<!-- 底部发送信息 -->
<view class="footerCon" :class="active ? 'on' : ''" :style="'transform: translate3d(0,' + percent + '%,0);'" ref="footerCon">
<view class="footer row-bottom" ref="footer">
<image :src="active? '/static/images/keyboard.png'
: '/static/images/face.png'
" @click="emoticon" />
<easy-upload ref="easyUpload" :uploadCount="9" :toUpload="false" :uploadIcon="'photo-fill'" :uploadIconColor="'#1423fc'" :isShowIcon="true" @successImage="successImage"></easy-upload>
<input type="text" placeholder="请输入内容" class="input" ref="input" @focus="focus" cursor-spacing="20" v-model="message" confirm-type="send" @confirm="sendTest('')" />
<view class="send" @click="sendTest('')">
发送
</view>
</view>
<!-- 表情 -->
<view class="bottom-icon" v-if="active">
<Emotion @emotion="handleEmotion"></Emotion>
</view>
</view>
</view>
</template>
<script>
import Emotion from "./emotion";
import $emo from "./constEmo.js";
export default {
components: {
Emotion,
},
data() {
return {
defaultAvatar: '/static/images/f.png',
active: false, //表情展示
message: '', //输入信息
userInfo: '', //用户信息
chatList: [], //信息列表
knowledgeInfo: '', //知识库
agentUser: '', //缓存信息
constData: this.$constData,
page: 1,
pageSize: 10,
totalPage: -1,//总聊天记录页数
freshing: false,
// 超出限制数组
model: 'product',
pid: 1,
percent: 0,
refreshTriggered: true,
isSocketOpen: false,//webscoket是否已经打开
scrollMsgIdx: 0, // 滚动条定位为到哪条消息
isMorePage: false,//是否多屏数据
isSendKnowledge: false,
};
},
onLoad() {
// 创建连接
// console.log(this.wss()+'/api/kefu');
this.connectSocket();
//监听socket错误
uni.onSocketError(()=>{
this.isSocketOpen=false;
})
//监听socket错误
uni.onSocketClose(()=>{
this.isSocketOpen=false;
})
//监听服务器内容
uni.onSocketMessage((res) => {
console.log('收到服务器内容:', JSON.parse(res.data));
this.getSocketData(JSON.parse(res.data))
});
},
onUnload(){
uni.closeSocket();
},
methods: {
//连接scoket
connectSocket(){
let socket = uni.connectSocket({
url: 'wss://mall.cn/api/kefu',//自己的链接地址
success: (res => {
}),
});
//监听
socket.onOpen((res)=> {
this.isSocketOpen=true;
this.hanldeSendSocketMessage(); //首次进入授权
})
},
// 数据处理
getSocketData(res) {
if (res.code == 401) {
this.hanldeSendSocketMessage();
return
}
if (res.code != 200) return
let data = res.data;
//授权信息,留言,坐席繁忙将返回的坐席存在缓存数据
if (res.code == 200 && ['auth', 'level_msg', 'agent_busy'].includes(data.messageType)) {
this.agentUser = data;
}
//获取到正确的数据
if (data.messageType) {
switch (data.messageType) {
case 'auth': // 建立连接,不做任何处理
this.tips = '';
break;
case 'knowledge': // 首次进入获取知识库
this.knowledgeInfo = data;
this.addMessage(data); //获取知识库列表
break;
case 'query_msg_list'://获取消息列表
this.loadMessageSuccess(data);
break;
case 'leave_msg'://留言
this.addMessage(data.extra.content);
break;
case 'agent_busy'://坐席繁忙
this.tips = data.extra.tips;
this.addMessage(data.extra.content);
break;
default:
this.addMessage(data);
break;
}
}
},
//获取聊天消息的高度
getChatHeight(){
if(!this.isMorePage){
const query = uni.createSelectorQuery().in(this);
query.select('#kefu-content').boundingClientRect(data => {
if (data) {
let systemInfo = uni.getStorageSync('systemInfo');
let editHeight = this.active ? 380:65;//有表情框
let toMoreHeight = 0;
if(this.isSendKnowledge){//点击知识库
toMoreHeight = 60
}
let caHeight = Number(systemInfo.windowHeight) - Number(editHeight)-Number(data.height)- toMoreHeight;
this.isMorePage = Number(caHeight) <= 0;//是否只有一屏//内容的高度大于可用窗口的高度-输入文字的高度
this.isSendKnowledge = false;
}
}).exec();
}
},
//添加消息
addMessage(data) {
//当前是客户还是坐席
let isMe = data.sendType == 'CUST'; //如果是客服
//客户,获取当前用户缓存中的数据
let custName = "";
let avatar = "";
if (isMe) {
custName = this.userInfo.nickname;
avatar = this.userInfo.avatar;
} else {
custName = this.agentUser.content.custName;
avatar = this.agentUser.content.avatar;
}
let params = {
id: Math.random(),
custName,
message: data.message,
createTimeStr: data.createTime,
isMe, //是否是我发送
avatar: avatar,
messageType: data.messageType,
};
this.chatList.unshift(params);
setTimeout(()=>{
this.$nextTick(() => {
// 数据和DOM都更新完成后,获取容器高度
this.getChatHeight();
});
},1000)
setTimeout(()=>{
this.scrollToBottom();
},100)
},
loadMessageSuccess(data) {
this.refreshTriggered = false;
//消息返回来的数据,进行处理
// 如果返回的页数跟当前的页数相同,不做任何处理
let list = data.content.list.map(item => {
return {
...item,
isMe: item.sendType == 'CUST',//是否是自己
avatar: item.isMe ? this.userInfo.avatar : ''
}
})
list.forEach(v => v.avatar = v.isMe ? this.userInfo.avatar : '')
this.chatList = this.chatList.concat(list);
this.page += 1;
this.totalPage = data.content.totalPage;
setTimeout(() => {
// 数据加载完成后更新数据状态
this.$nextTick(() => {
// 数据和DOM都更新完成后,获取容器高度
this.getChatHeight();
});
}, 1000); // 假设数据加载需要1秒钟
},
scrollToBottom(){
let size = this.chatList.length;
if (size > 0) {
this.scrollToMsgIdx(size - 1);
}
},
scrollToMsgIdx(idx) {
// 如果scrollMsgIdx值没变化,滚动条不会移动
if (idx == this.scrollMsgIdx && idx > 0) {
this.$nextTick(() => {
// 先滚动到上一条
this.scrollMsgIdx = idx - 1;
// 再滚动目标位置
this.scrollToMsgIdx(idx);
});
return;
}
this.$nextTick(() => {
this.scrollMsgIdx = idx;
});
},
//授权
hanldeSendSocketMessage() {
this.userInfo = JSON.parse(uni.getStorageSync('USER_INFO'));
let params = {
sendType: "CUST",
messageType: "auth",
content: {
avatar: this.userInfo.avatar,
custId: this.userInfo.uid,
custName: this.userInfo.nickname
}
}
this.handleSendSocketMessage(params)
},
focus() {
this.active = false;
this.getChatHeight();
},
//上传图片
successImage(value) {
value.forEach(img=>{
let content = `<img src='${img.url}'" class="chat-image"></img>`;
let data = {
token: this.agentUser.token,
messageType: 'text',
content: {
messageType: 'text',
message: content
}
};
this.handleSendSocketMessage(data)
})
},
//发送消息
sendTest(message,flag) {
this.isSendKnowledge = flag || false;
if (!this.message && !message) {
return uni.showToast({
title: '消息为空',
icon: 'none'
})
}
if (!this.userInfo) {
//不存在用户信息,退出登录
// getLogout().then((res) => {
// store.commit("LOGOUT");
// // uni.clearStorageSync();
// uni.reLaunch({
// url: "/pages/index/index",
// });
// getApp().globalData.cartCounts = 0;
// store.commit("LOGIN", {
// token: '',
// });
// store.commit("UPDATE_USERINFO", null);
// store.commit("SETUID", null);
// })
return;
}
let content = message || this.message;
let data = {
token: this.agentUser.token,
messageType: 'text',
content: {
messageType: 'text',
message: content
}
};
this.handleSendSocketMessage(data)
setTimeout(() => {
if (!message) {
this.message = "";
}
}, 100 * 1);
},
//选择表情
handleEmotion(i) {
let word = i.replace(/#|;/gi, "");
let index = $emo.list.indexOf(word);
let content = `<img class="chat-emtion" src="https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/${index}.gif" align="middle">`;
let data = {
token: this.agentUser.token,
messageType: 'text',
content: {
messageType: 'text',
message: content
}
};
this.handleSendSocketMessage(data)
this.active = false;
},
//send 发送消息
handleSendSocketMessage(data) {
if(!this.isSocketOpen){//暂未连接
this.connectSocket();
return;
}
uni.sendSocketMessage({
data: JSON.stringify(data),
});
},
emoticon() {
this.active = !this.active;
this.getChatHeight();
},
//切换用户或结束会话
sendSession(messageType) {
//发送数据给服务端,标记
if (!this.agentUser || !this.userInfo) {
return;
}
let data = {
sendType: 'CUST',
messageType: messageType,
agentId: this.agentUser.content.custId,
custId: this.userInfo.uid,
token: this.agentUser.token,
content: {}
}
if (messageType == 'query_msg_list') {//加载消息列表
data = Object.assign(data,
{
page: this.page,
pageSize: this.pageSize
}
)
}
this.handleSendSocketMessage(data, true)
},
// 自定义下拉刷新被触发
onRefresh() {
if (this.freshing) return;
if (this.page > this.totalPage) {
this.refreshTriggered = false
return;
}
this.refreshTriggered = true;
this.freshing = true;
this.sendSession('query_msg_list');
setTimeout(() => {
this.freshing = false;
}, 500);
},
},
}
</script>
<style lang="scss">
.scroll-h {
max-height: 100%;
padding-bottom: 20upx;
overflow-anchor: auto;
transform: rotate(180deg);
}
.autoHeight{
height: calc(100% - 65px);
overflow-y: auto;
}
.scroll-h-icon.autoHeight {
height: calc(100% - 380px);
overflow-y: auto;
}
.user-chat-info {
flex: 1;
}
.user-info{
margin-bottom: 10upx;
}
.order-2.user-info {
text-align: right;
}
.setting {
min-height: auto;
background: #f5f5f5;
}
.kefu-box {
padding: 0 20upx;
height: 100vh;
.content {
transform: rotate(180deg);
display: flex;
// align-items: center;
padding-bottom: 35upx;
}
.avatar-image {
width: 60upx;
height: 60upx;
border-radius: 50%;
margin: 10upx 10upx;
}
.content-text-knowledge {
padding: 10upx 15upx;
background-color: #fff !important;
border-radius: 5px;
margin-top: 10upx;
position: relative;
.knowledge-title {
font-size: 28upx;
font-weight: 550;
margin-bottom: 20upx;
}
.border-none {
:last-child {
border: none !important;
}
.iconfont {
font-size: 24upx !important;
}
}
.knowledge-flxe {
min-width: 60upx;
max-width: 600upx;
padding: 10px 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
border-bottom: 1px solid #f5f5f5;
font-size: 24upx;
}
}
.content-text {
// background-color: #c7dcfa;
margin-top: 10upx;
position: relative;
.text-span {
max-width: 600upx;
border-radius: 5px;
padding: 10upx 15upx;
display: inline-block;
background-color: #c7dcfa;
text-align: left;
}
.avatar-image {
max-width: 300upx;
max-height: 300upx;
}
}
.online-time {
color: #666;
font-size: 20upx;
}
}
.user-chat-name {
color: #000;
}
.setting-item {
padding: 25upx 20upx;
border-bottom: 2upx #dfdfdf solid;
font-size: 26upx;
}
.avatar {
width: 60upx;
height: 60upx;
border-radius: 50%;
overflow: hidden;
}
.item-value {
color: #999999;
}
.send {
width: 70upx;
text-align: center;
height: 60upx;
line-height: 60upx;
font-weight: bold;
}
.input {
max-height: 150upx;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
margin: 0 10upx;
border-radius: 10upx;
background-color: #e5e5e5;
/* padding: 17upx 30upx; */
height: 60upx;
padding: 0 10upx;
}
.footer {
width: 100%;
background-color: #fff;
padding: 17upx 26upx;
display: flex;
align-items: center;
}
.footer image {
width: 61upx;
height: 60upx;
display: block;
}
.footerCon {
position: fixed;
bottom: 0upx;
min-height: 100upx;
width: 100%;
transition: all 0.005s cubic-bezier(0.25, 0.5, 0.5, 0.9);
background-color: #fff;
}
.footerCon .on {
bottom: 300upx;
transform: translate3d(0, 0, 0) !important;
}
.bottom-icon {
padding: 15upx;
}
.chat-image{
max-width: 375upx !important;
}
.load-more{
text-align: center;
color: #1423fc;
padding: 5px 0;
transform: rotate(180deg);
}
</style>
用于渲染表情的emotion.vue组件
<template>
<div class="emotion-box" :style="{ height: height + 'px' }">
<div class="emotion-item" v-for="(item, index) in list" :key="index" @click="clickHandler(item)">
<img class="ly-static-emotion" :src="'https://res.wx.qq.com/mpres/htmledition/images/icon/emotion/' + index + '.gif'">
</div>
</div>
</template>
<script>
import $emo from './constEmo.js'
export default {
props: {
height: {
type: Number,
default: 300,
}
},
data() {
return {
list: $emo.list
}
},
methods: {
clickHandler(i) {
let emotion = `#${i};`
this.$emit('emotion', emotion)
}
},
}
</script>
<style scoped>
.emotion-box {
margin: 0 auto;
width: 100%;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.emotion-box:after{
content:'';
flex: 1;
}
.emotion-item{
width: 40px;
height: 40px;
position: relative;
z-index: 1;
margin: 0 auto 15px;
padding: 0 5px;
}
.ly-static-emotion{
width: 30px;
height: 30px;
margin: 0 5px 15px;
display: block;
}
</style>
表情constEmo.js
export default {
list: ['微笑', '撇嘴', '色', '发呆', '得意', '流泪', '害羞', '闭嘴', '睡', '大哭', '尴尬', '发怒', '调皮', '呲牙', '惊讶', '难过', '酷', '冷汗', '抓狂', '吐', '偷笑', '可爱', '白眼', '傲慢', '饥饿', '困', '惊恐', '流汗', '憨笑', '大兵', '奋斗', '咒骂', '疑问', '嘘', '晕', '折磨', '衰', '骷髅', '敲打', '再见', '擦汗', '抠鼻', '鼓掌', '糗大了', '坏笑', '左哼哼', '右哼哼', '哈欠', '鄙视', '委屈', '快哭了', '阴险', '亲亲', '吓', '可怜', '菜刀', '西瓜', '啤酒', '篮球', '乒乓', '咖啡', '饭', '猪头', '玫瑰', '凋谢', '示爱', '爱心', '心碎', '蛋糕', '闪电', '炸弹', '刀', '足球', '瓢虫', '便便', '月亮', '太阳', '礼物', '拥抱', '强', '弱', '握手', '胜利', '抱拳', '勾引', '拳头', '差劲', '爱你', 'NO', 'OK', '爱情', '飞吻', '跳跳', '发抖', '怄火', '转圈', '磕头', '回头', '跳绳', '挥手', '激动', '街舞', '献吻', '左太极', '右太极']
}
注意的点:
- 消息在发送后自动回到底部,此处使用的是scroll-view 中的:scroll-into-view属性,值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
- 滑动到顶部下拉加载更多历史消息,@scrolltolower,消息数据在渲染视图时,将scroll-view设置样式transform: rotate(180deg);再将内部聊天元素设置transform: rotate(180deg);实际是滑动到底部下拉加载更多,视觉效果是滑动到顶部