聊天列表
<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
}