聊天chat封装

  1. 说明:连接状态,客户端ID,在线状态,连接中,当前聊天会话ID,当前聊天对象ID,总未读数,
  2. 聊天功能实现首先要保证当前用户已经登录状态
  3. 监听登录时更新会话列表
  4. 监听退出时更新会话列表
  5. 发起聊天的时候,其他人个人空间的时候核心按钮旁边有一个聊天的入口按钮点击不仅要做进入聊天页面还要更新tabbar页面(下面简称会话列表)的更新会话列表(相当于发起聊天)并且会话置顶
  6. 根据聊天记录最后一条消息更新会话列表
  7. 进入会话列表开启接收消息相关补充一点下拉刷新携带参数为回调函数用于showToast和关闭下拉刷新操作其他为null
  8. 进入会话详情页面此时采用和微信历史聊天记录一样是用的翻转180度,另外chat-item组件外需要包一层view且需要margin为auto让他剧中避免一条消息的时候处于底部,当然页面布局结束,进入详情页面也是需要跟新消息的我们打开$on的监听add数据即可,但是追加好数据之后要让其滚动到底部即用scrollTop可以巧用1px置空为0让他接收到消息始终在底部this.scrollTop == 1 ? 0 : 1,其实也好理解就是soket.uts中监听消息根据后端返回type通知是否是绑定设备(用户上线)跟新会话列表还是会话详情的消息即可
  9. 更新总未读数后端处理了,我们只需要进入会话详情(两个接口一个获取聊天记录一个查看当前会话聊天记录)之后调用已读接口拿到数据之后setTabBarBadge配置,查看接口调用后携带会话详情id(已读)通知会话列表更新,会话列表更新做过滤并拿到返回值数量unread_count
  10. 这里说一下啊像\(on和\)emit全局做了很多同步更新,我们都需要在页面或者组件销毁的时候关闭一下$off
  11. websocket如果监听失败断开连接了用户下线,连接关闭,我们需要尝试重连(心跳),下面代码socket.uts已经处理了

聊天列表

<template>
	<!-- #ifdef APP -->
	<scroll-view style="flex:1">
	<!-- #endif -->
		<view class="msg-item" hover-class="msg-item-hover" v-for="(item,index) in list" :key="index" @click="openChat(item)">
			<avatar :src="item.avatar" width="100rpx" height="100rpx" style="margin-right: 20rpx;"></avatar>
			<view class="msg-item-body">
				<text class="msg-item-nickname">{{ item.name }}</text>
				<text class="msg-item-content">{{ item.last_msg_note }}</text>
			</view>
			<view class="msg-item-info">
				<text class="msg-item-time">{{ item.update_time }}</text>
				<text class="msg-item-badge" v-if="item.unread_count > 0">{{ item.unread_count > 99 ? '99+' : item.unread_count }}</text>
			</view>
		</view>
		
		<!-- 暂无数据 -->
		<tip v-if="!isFirstLoad && list.length == 0"></tip>
		
		<loading-more v-if="isFirstLoad || list.length > 0" :loading="loading" :isEnded="isEnded"></loading-more>
	<!-- #ifdef APP -->
	</scroll-view>
	<!-- #endif -->
</template>

<script>
	import { Conversation,ConversationResult,Result } from '@/common/type.uts';
	import { getURL } from "@/common/request.uts"
	import { getToken,loginState } from "@/store/user.uts"
	import { openSocket } from "@/common/socket.uts"
	export default {
		data() {
			return {
				list: [] as Conversation[],
				loading: false,
				isEnded: false,
				currentPage: 1,
				isFirstLoad:true
			}
		},
		onLoad() {
			this.refreshData(null)
			// 监听会话变化
			uni.$on("onUpdateConversation",this.onUpdateConversation)
			uni.$on("onUpdateNoReadCount",this.onUpdateNoReadCount)
		},
		onShow(){
			if(this.loginState){
				openSocket()
			}
		},
		onUnload() {
			uni.$off("onUpdateConversation",this.onUpdateConversation)
			uni.$off("onUpdateNoReadCount",this.onUpdateNoReadCount)
		},
		onPullDownRefresh() {
			this.refreshData(()=>{
				uni.showToast({
					title: '刷新成功',
					icon: 'none'
				});
				uni.stopPullDownRefresh()
			})
		},
		onReachBottom() {
			this.loadData(null)
		},
		computed: {
			// 登录状态
			loginState(): boolean {
				return loginState.value
			}
		},
		methods: {
			onUpdateNoReadCount(id:number){
				let item = this.list.find((o:Conversation):boolean => o.id == id)
				if(item != null){
					item.unread_count = 0
				}
			},
			// 监听会话变化
			onUpdateConversation(e:Conversation | null){
				// 登录或者退出触发
				if(e == null){
					// 已登录,直接刷新数据
					if(this.loginState){
						this.refreshData(null)
					}
					// 退出登录,清除会话列表
					else {
						this.list.length = 0
					}
					return
				}
				// 发起会话 或 聊天中 触发
				// 查询会话是否存在
				let i = this.list.findIndex((o:Conversation):boolean => {
					return o.id == e.id
				})
				// 不存在直接刷新
				if(i == -1){
					this.refreshData(null)
					return
				}
				// 存在则修改并置顶
				this.list[i].avatar = e.avatar
				this.list[i].name = e.name
				this.list[i].last_msg_note = e.last_msg_note
				this.list[i].unread_count = e.unread_count
				this.list[i].update_time = e.update_time
				this._toFirst(this.list,i)
			},
			// 数组置顶
			_toFirst(arr: Conversation[], index : number) : Conversation[]{
				if(index != 0){
					arr.unshift(arr.splice(index,1)[0])
				}
				return arr;
			},
			openChat(item : Conversation){
				uni.navigateTo({
					url: `/pages/chat/chat?id=${item.id}&target_id=${item.target_id}&title=${item.name}`
				});
			},
			refreshData(loadComplete : (() => void) | null) {
				this.list.length = 0
				this.currentPage = 1
				this.isFirstLoad = true
				this.isEnded = false
				this.loading = false
				this.loadData(loadComplete)
			},
			loadData(loadComplete : (() => void) | null) {
				if (this.loading || this.isEnded) {
					return
				}
				this.loading = true
				uni.request<Result<ConversationResult>>({
					url: getURL(`/im/conversation/${Math.floor(this.currentPage)}`),
					header:{
						token:getToken()
					},
					success: (res) => {
						let r = res.data
						if(r == null) return
						if(res.statusCode !=200){
							uni.showToast({
								title: r.msg,
								icon: 'none'
							});
							return
						}
						
						const resData = r.data as ConversationResult | null
						if(resData == null) return
						
						// 是否还有数据
						this.isEnded = resData.last_page <= resData.current_page
						if(this.currentPage == 1){
							this.list = resData.data
						} else {
							this.list.push(...resData.data)
						}
						
						// 页码+1
						this.currentPage = this.isEnded ? resData.current_page : Math.floor(resData.current_page + 1)
					},
					fail: (err) => {
						uni.showToast({
							title: err.errMsg,
							icon: 'none'
						});
					},
					complete: () => {
						this.loading = false
						this.isFirstLoad = false
						if (loadComplete != null) {
							loadComplete()
						}
					}
				})
			},
		}
	}
</script>

<style>
.msg-item {
	flex-direction: row;
	align-items: stretch;
	padding: 20rpx 30rpx;
}
.msg-item-hover {
	background-color: #f4f4f4;
}
.msg-item-body {
	max-width: 420rpx;
}
.msg-item-nickname {
	font-size: 17px;
	font-weight: bold;
	margin: 10rpx 0;
	lines: 1;
}
.msg-item-content {
	font-size: 14px;
	color: #727272;
	lines: 1;
}
.msg-item-info {
	margin-left: auto;
	align-items: flex-end;
	flex-shrink: 0;
}
.msg-item-time {
	font-size: 12px;
	color: #777777;
	margin: 10rpx 0;
}
.msg-item-badge {
	color: #ffffff;
	background-color: #f84c2f;
	font-size: 11px;
	padding: 4rpx 8rpx;
	border-radius: 30rpx;
	font-weight: bold;
}
</style>

聊天详情

<template>
	<scroll-view :scroll-top="scrollTop" class="chat-scroller" :scroll-with-animation="true" @scrolltolower="loadData(null)">
		<view style="margin-top: auto;">
			<chat-item v-for="(item,index) in list" :key="index" :item="item"></chat-item>
			<view class="loadMore" v-if="list.length > 5">
				<loading-more :isEnded="isEnded" :loading="loading"></loading-more>
			</view>
		</view>
	</scroll-view>
	<view class="chat-action">
		<textarea :auto-focus="false" class="chat-input" :auto-height="true" v-model="content" placeholder="说几句吧" />
		<main-btn width="100rpx" height="60rpx" font-size="14px" :disabled="content == '' || sendLoading"
			style="margin-left: 10rpx;margin-bottom: 5rpx;" @click="send">{{ sendLoading ? '发送中' : '发送' }}</main-btn>
	</view>
</template>

<script>
	import { ChatItem,ChatItemResult,Result,Conversation } from "@/common/type.uts"
	import { getURL } from "@/common/request.uts"
	import { getToken } from "@/store/user.uts"
	import { setCurrentConversation } from "@/common/socket.uts"
	export default {
		data() {
			return {
				content: "",
				list: [] as ChatItem[],
				isEnded: false,
				loading: false,
				currentPage: 1,
				sendLoading: false,
				scrollTop:0,
				id:0,
				target_id:0
			}
		},
		onLoad(options:OnLoadOptions) {
			// 会话ID
			if(options.has("id")){
				this.id = parseInt(options.get("id") as string)
			}
			// 聊天对象ID
			if(options.has("target_id")){
				this.target_id = parseInt(options.get("target_id") as string)
			}
			// 页面标题
			if(options.has("title")){
				const title = options.get("title") as string
				uni.setNavigationBarTitle({
					title
				})
			}
			// 设置当前聊天对象
			setCurrentConversation(this.id, this.target_id)
			// 获取聊天记录
			this.refreshData(null)
			// 监听接收信息
			uni.$on("onMessage",this.onMessage)
			// 更新未读数
			this.read()
		},
		onUnload() {
			// 删除当前聊天对象
			setCurrentConversation(0, 0)
			uni.$off("onMessage",this.onMessage)
		},
		methods: {
			// 接收消息
			onMessage(e:ChatItem){
				console.log("onMessage",e)
				// 属于当前会话,直接添加数据
				if(e.conversation_id == this.id){
					// 将数据渲染到页面
					this.addMessage(e)
					// 更新未读数
					this.read()
				}
			},
			// 更新未读数
			read(){
				uni.request<Result<Conversation>>({
					url: getURL(`/im/read_conversation/${this.id}`),
					method: 'POST',
					header:{
						token:getToken()
					},
					success: res => {
						let r = res.data
						if(r == null) return
						if(res.statusCode != 200){
							uni.showToast({
								title: r.msg,
								icon: 'none'
							});
							return
						}
						const resData = r.data as Conversation | null
						if(resData == null) return
						// 通知聊天会话列表更新未读数
						uni.$emit("onUpdateNoReadCount",resData.id)
					},
					fail: (err) => {
						uni.showToast({
							title: err.errMsg,
							icon: 'none'
						});
					},
				});
			},
			refreshData(loadComplete : (() => void) | null) {
				this.list.length = 0
				this.currentPage = 1
				this.isEnded = false
				this.loading = false
				this.loadData(loadComplete)
			},
			loadData(loadComplete : (() => void) | null) {
				if (this.loading || this.isEnded) {
					return
				}
				this.loading = true
				uni.request<Result<ChatItemResult>>({
					url: getURL(`/im/${this.id}/message/${Math.floor(this.currentPage)}`),
					header:{
						token:getToken()
					},
					success: (res) => {
						let r = res.data
						if(r == null) return
						if(res.statusCode !=200){
							uni.showToast({
								title: r.msg,
								icon: 'none'
							});
							return
						}
						
						const resData = r.data as ChatItemResult | null
						if(resData == null) return
						
						// 是否还有数据
						this.isEnded = resData.last_page <= resData.current_page
						if(this.currentPage == 1){
							this.list = resData.data
						} else {
							this.list.push(...resData.data)
						}
						
						// 页码+1
						this.currentPage = this.isEnded ? resData.current_page : Math.floor(resData.current_page + 1)
					},
					fail: (err) => {
						uni.showToast({
							title: err.errMsg,
							icon: 'none'
						});
					},
					complete: () => {
						this.loading = false
						if (loadComplete != null) {
							loadComplete()
						}
					}
				})
			},
			send() {
				this.sendLoading = true

				uni.request<Result<ChatItem>>({
					url:getURL("/im/send"),
					method:"POST",
					header:{
						token:getToken()
					},
					data: {
						target_id:this.target_id,
						type:"text",
						body:this.content,
						client_create_time: Date.now()
					},
					success:(res)=>{
						let r = res.data
						if(r == null) return
						if(res.statusCode != 200){
							uni.showToast({
								title: r.msg,
								icon: 'none'
							});
							return
						}
						if(r.data == null) return
						let d = r.data as ChatItem
						/**
						 * 消息状态state:
						 * 100 发送成功
						 * 101 对方已把你拉黑
						 * 102 你把对方拉黑了
						 * 103 对方已被系统封禁
						 * 104 禁止发送(内容不合法)
						 */
						if(d.state != 100){
							let title = d.state_text != null ? d.state_text as string : '发送失败'
							uni.showToast({
								title,
								icon: 'none'
							});
						}
						
						this.addMessage(d)
						this.content = ""
					},
					fail:(err)=>{
						uni.showToast({
							title: err.errMsg,
							icon: 'none'
						});
					},
					complete:()=>{
						this.sendLoading = false
					}
				})
			},
			// 添加数据
			addMessage(e:ChatItem){
				// 将最新的数据追加到列表头部
				this.list.unshift(e)
				this.goToBottom()
			},
			// 滚动到底部
			goToBottom(){
				setTimeout(()=>{
					this.scrollTop = this.scrollTop == 1 ? 0 : 1
				},300)
			}
		}
	}
</script>

<style>
	.chat-scroller {
		flex: 1;
		box-sizing: border-box;
		transform: rotate(180deg);
	}

	.loadMore {
		transform: rotate(180deg);
	}

	.chat-action {
		min-height: 95rpx;
		flex-direction: row;
		align-items: flex-end;
		background-color: #ffffff;
		border-top: 1px solid #eeeeee;
		padding-left: 28rpx;
		padding-right: 28rpx;
		padding-bottom: 20rpx;
		flex-shrink: 0;
	}

	.chat-input {
		width: 590rpx;
		background-color: #f4f4f4;
		border-radius: 5px;
		padding: 16rpx 20rpx;
		margin-top: 20rpx;
		max-height: 500rpx;
	}
</style>

socket.uts

import { websocketURL } from "@/common/config.uts"
import { defaultResult,ChatItem,Conversation } from '@/common/type.uts';
import { getURL } from '@/common/request.uts';
import { getToken } from '@/store/user.uts';
// 连接状态
export const isConnect = ref<boolean>(false)
// 客户端ID
const client_id = ref<string>("")
// 在线状态
export const isOnline = ref<boolean>(false)
// 连接中
export const onlining = ref<boolean>(false)
// 当前聊天会话ID
export const current_conversation_id = ref<number>(0)
// 当前聊天对象ID
export const current_target_id = ref<number>(0)
// 总未读数
export const total_unread_count = ref<number>(0)

// 设置当前会话信息
export function setCurrentConversation(conversation_id : number, target_id : number){
	current_conversation_id.value = conversation_id
	current_target_id.value = target_id
	
}

// 打开websocket
export function openSocket(){
	// 绑定上线(防止用户处于离线状态)
	handleBindOnline()
	// 已连接,直接返回
	if(isConnect.value) return
	uni.connectSocket({
		url:websocketURL
	})
	// 监听打开
	uni.onSocketOpen((_)=>{
		console.log("已连接")
		isConnect.value = true
		// 重置重连次数
		resetReconnectAttempts()
	})
	// 监听关闭
	uni.onSocketClose((res:OnSocketCloseCallbackResult)=>{
		// 已断开
		isConnect.value = false
		client_id.value = ""
		isOnline.value = false
		if(res.code == 1000){
			console.log("websocket已干净关闭,未尝试重新连接")
		} else {
			console.log("websocket意外断开,正在尝试重新连接")
			reconnect()
		}
	})
	// 监听失败
	uni.onSocketError((res:OnSocketErrorCallbackResult)=>{
		// 已断开
		isConnect.value = false
		client_id.value = ""
		isOnline.value = false
		console.log("失败 socket")
		console.log(res)
	})
	
	// 监听接收消息
	uni.onSocketMessage((res:OnSocketMessageCallbackResult)=>{
		console.log("消息 socket")
		let d = JSON.parse(res.data as string) as UTSJSONObject
		const type = d.get("type") as string
		switch (type){
			case "bind": // 绑定上线
			client_id.value = d.get("data") as string
			handleBindOnline()
				break;
			case "message": // 接收消息
			let data2 = JSON.parse<ChatItem>(JSON.stringify(d.get("data")))
			uni.$emit("onMessage",data2)
				break;
			case "conversation": // 更新会话列表
			let data1 = JSON.parse<Conversation>(JSON.stringify(d.get("data")))
			uni.$emit('onUpdateConversation',data1)
				break;
			case "total_unread_count": // 总未读数更新
			total_unread_count.value = d.get("data") as number
			let total = total_unread_count.value
			if(total > 0){
				uni.setTabBarBadge({
					index:2,
					text:total > 99 ? "99+" : total.toString()
				})
			} else {
				uni.removeTabBarBadge({
					index: 2
				})
			}
				break;
		}
	})
}

// 关闭socket
export function closeSocket(){
	uni.closeSocket({ code:1000 })
}

// 绑定上线
export function handleBindOnline(){
	if(isConnect.value && client_id.value != '' && !isOnline.value && !onlining.value){
		onlining.value = true
		const cid = client_id.value as string
		uni.request<defaultResult>({
			url: getURL("/im/bind_online"),
			method: 'POST',
			header: {
				token:getToken()
			},
			data: {
				client_id:cid
			},
			success: res => {
				let r = res.data
				if(r == null) return
				// 请求失败
				if(res.statusCode != 200){
					uni.showToast({
						title: r.msg,
						icon: 'none'
					});
					return
				}
				isOnline.value = true
				console.log("用户上线")
			},
			fail: (err) => {
				uni.showToast({
					title: err.errMsg,
					icon: 'none'
				});
			},
			complete: () => {
				onlining.value = false
			}
		});
	}
}


// 已经重连次数
let reconnectAttemptCount = ref<number>(0)
// 最大自动重连数
let reconnectAttempts = 5
// 重连倒计时定时器
let reconnectInterval = 0

function reconnect():void {
	console.log("重连中...")
	// 如果没有超过最大重连数,继续
	if(reconnectAttemptCount.value < reconnectAttempts){
		// 重连次数+1
		reconnectAttemptCount.value++
		// 延迟重连
		reconnectInterval = setTimeout(()=>{
			openSocket()
		}, getReconnectDelay(reconnectAttemptCount.value))
	} else {
		console.log("已经达到最大重连尝试次数")
	}
}

// 获取重连倒计时
function getReconnectDelay(attempt:number) : number {
	// 最小延迟时间(毫秒)
	const baseDelay = 1000;
	// 最大延迟时间(毫秒)
	const maxDelay = 10000;
	// 根据已经重连次数,计算出本次重连倒计时
	const delay = baseDelay * (2 * attempt) + Math.random() * 1000
	// 取最小值
	return Math.min(delay,maxDelay)
}

// 重置重连次数
function resetReconnectAttempts():void {
	if(reconnectInterval > 0){
		clearInterval(reconnectInterval)
		reconnectInterval = 0
	}
	reconnectAttemptCount.value = 0
}