记一次uniapp实现简易版在线聊天

3,396 阅读4分钟

记一次uniapp实现简易版在线聊天

近期在实现uniapp各平台应用 ,其中有一个功能是实现简单的聊天界面功能,虽然听起来是一个很简单的功能,但是在开发的时候会遇到各种问题,经过上网查找解决了自己的问题,下面我记录一下。 废话不多说,直接上效果图,详细代码

59fad114f1d266bddced4fdbb1c90475.gif

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', '爱情', '飞吻', '跳跳', '发抖', '怄火', '转圈', '磕头', '回头', '跳绳', '挥手', '激动', '街舞', '献吻', '左太极', '右太极']
}

注意的点:

  1. 消息在发送后自动回到底部,此处使用的是scroll-view 中的:scroll-into-view属性,值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
  2. 滑动到顶部下拉加载更多历史消息,@scrolltolower,消息数据在渲染视图时,将scroll-view设置样式transform: rotate(180deg);再将内部聊天元素设置transform: rotate(180deg);实际是滑动到底部下拉加载更多,视觉效果是滑动到顶部