聊天chat封裝

jialiangzai發表於2024-07-14
聊天列表
<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
}

相關文章