<template>
<div class="container" ref="chatWrapper">
<div class="header">
<head-bar class="chat-head" :title="'Chat'" @goback="goBack"></head-bar>
</div>
<div class="content" ref="chatContainer">
<lottieLoad v-if="loadingMore"></lottieLoad>
<div
class="chat-item"
:id="`chat-item-${index}`"
v-for="(item, index) in chatList"
:key="index"
:style="{ flexDirection: item.sender === 'me' ? 'row-reverse' : 'row' }"
>
<div class="message-avatar">
<img v-if="item.from_client_avatar" :src="item.from_client_avatar" />
<img v-else src="@/assets/images/userProfile.png" alt="" />
</div>
<div class="message-content">
<div
class="message-header"
:style="{ textAlign: item.sender === 'me' ? 'right' : 'left' }"
>
<span class="message-username">{{ item.from_client_name }}</span>
<span class="message-time">
{{ item.time ? item.time.split(' ')[1].substr(0, 5) : '' }}
</span>
</div>
<div
class="message-text"
v-html="item.content"
v-if="!item.is_img"
:style="{ float: item.sender === 'me' ? 'right' : 'left' }"
></div>
<div
v-else
class="message-text massage-img"
@click="showMoreImg(item, index)"
:style="{ float: item.sender === 'me' ? 'right' : 'left' }"
>
<!-- <van-image :src="item.content">
@click="showMoreImg(item, index)"
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
</van-image> -->
<img v-if="item.content" :src="item.content" alt="" />
</div>
</div>
</div>
<div class="toBottom" @click="scrollToBottom1" v-if="newMessageNum > 0">
<img src="@/assets/icons/new-message.png" alt="" />
<span class="msg-num">{{ newMessageNum }}</span>
<span>See the latest news</span>
</div>
</div>
<!-- 聊天输入 -->
<div class="footer">
<van-row gutter="10">
<van-col span="18">
<div class="searchInput" ref="mesageInput">
<V3Emoji
size="small"
:textAreaOption="{ placeholder: 'Your Message' }"
:custom-theme="customTheme"
:textArea="true"
ref="emoji"
v-model="chatContent"
class="emoji"
>
<img
class="publicIcon photoImg"
src="@/assets/icons/photoAlbum.png"
alt=""
@click.stop="handlePhoto"
/>
<img class="publicIcon" src="@/assets/icons/emote.png" alt="" />
</V3Emoji>
</div>
</van-col>
<van-col span="6">
<van-button
style="height: 50px"
round
block
type="primary"
native-type="submit"
@click="sendMessage"
>
Send
<!-- {{ t('ey.send') }} -->
</van-button>
</van-col>
</van-row>
<!-- 图片 -->
<input
type="file"
ref="fileInput"
style="display: none"
accept="image/*"
@change="handleFileChange"
/>
</div>
</div>
</template>
js 部分。
<script lang="ts">
import {
defineComponent,
ref,
onMounted,
toRefs,
nextTick,
watch,
onUpdated,
computed,
} from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import headBar from '@/components/model/headModel.vue'
import V3Emoji from 'vue3-emoji'
import 'vue3-emoji/dist/style.css'
import websocket from '@/utils/websocket'
import { getChatRoomBind, uploadImg, getHistoryRecord } from '@/api/chatApi'
import { useI18n } from 'vue-i18n'
import lottieLoad from '@/components/model/lottieLoad.vue'
import $okToast from '@/views/mobile/compontents/tipsDialog'
import { load } from '@/views/mobile/compontents/lodding/index'
export default defineComponent({
name: 'userInfo',
components: {
headBar,
friendRequest,
V3Emoji,
lottieLoad,
},
setup() {
const { t } = useI18n()
const store = useStore()
const router = useRouter()
const chatList: any = ref([])
const isLogin = ref<boolean>(false)
let ws
const state = reactive({
friendShow: false,
})
watch(
() => store.state.chat.client_id,
(id) => {
client_id.value = id
}
)
const loadingMore = ref(false)
onMounted(async () => {
await getHistory()
const element: any = chatContainer.value
if (element) {
chatList.value.unshift(...sliceHistoryList.value.reverse())
await nextTick()
if (chatContainer.value) {
scrollToBottom()
}
element.addEventListener('scroll', (e) => {
let scrollH = element.scrollHeight
let clientH = element.clientHeight
let scrollT = element.scrollTop
if (clientH + scrollT >= scrollH) {
newMessageNum.value = 0
}
if (scrollT <= 0) {
if (
sliceNum.value <= historyList.value.length &&
!loadingMore.value
) {
sliceNum.value += 10
}
loadingMore.value = true
if (sliceHistoryList.value.length > 0) {
setTimeout(async () => {
chatList.value.unshift(...sliceHistoryList.value.reverse())
await nextTick()
let firstEle = element.querySelector(
`#chat-item-${sliceHistoryList.value.length}`
)
element.scrollTo({
top: firstEle.offsetTop - 42,
behavior: 'instant',
})
loadingMore.value = false
}, 1000)
} else {
setTimeout(() => {
loadingMore.value = false
// message.warning('no more')
}, 500)
}
}
})
}
})
const sliceNum = ref(0)
const newMessageNum = ref(0)
const client_id = ref()
const isBind = ref(false)
const chatContainer = ref()
//监听信息
const handleMessage = () => {
ws.onMessage((data) => {
let msg = JSON.parse(data)
if (msg.type == 'connect') {
client_id.value = msg.client_id
// store.commit('setClientId', msg.client_id)
if (isLogin.value) {
getChatRoomBind(client_id.value).then((res: any) => {
if (res.code == 200) {
isBind.value = true
}
})
}
} else if (msg.type == 'login') {
$okToast.show({
type: 'warn',
content: 'please login',
textAlign: 'left',
})
} else {
if (msg.from_user_id == store.getters.userInfo.user_id) {
msg.sender = 'me'
chatList.value.push(msg)
newMessageNum.value = 0
scrollToBottom()
} else {
msg.sender = 'other'
chatList.value.push(msg)
const element: any = chatContainer.value
nextTick(() => {
if (element) {
const lastElement = element.querySelector(
'.chat-item:last-child'
)
let observe = new IntersectionObserver((entries) => {
//观察该el是否进入可视区域
if (entries[0].isIntersecting) {
newMessageNum.value = 0
scrollToBottom()
} else {
newMessageNum.value += 1
}
//停止观察
observe.unobserve(lastElement)
})
//开始观察
observe.observe(lastElement)
}
})
}
}
})
}
watch(
() => store.getters.token,
(token) => {
if (token.access_token) {
isLogin.value = true
ws = new websocket('websocket地址')
handleMessage()
} else {
isLogin.value = false
ws && ws.close(0)
}
},
{
immediate: true,
}
)
watch(
() => store.state.dailyDate.chatStatus,
(flag) => {
if (flag) {
scrollToBottom()
}
}
)
// onUpdated(() => {
// if (newMessageNum.value == 0) {
// scrollToBottom()
// }
// })
//滚动到底部
const scrollToBottom = () => {
const element: any = chatContainer.value
const lastElement = element?.querySelector('.chat-item:last-child')
if (lastElement) {
setTimeout(() => {
newMessageNum.value = 0
lastElement.scrollIntoView({ behavior: 'smooth' })
}, 0)
}
}
const handleLink = () => {
router.push({ name: 'Demo' })
}
let socket: any = null
const handleCancle = () => {
state.friendShow = false
}
// 确认
const handleSubmit = () => {
state.friendShow = false
}
// e, isImg = false
const sendMessage = () => {
if (!isLogin.value) {
$okToast.show({
type: 'warn',
content: 'please login',
textAlign: 'left',
})
return
}
if (!isBind.value) {
$okToast.show({
type: 'warn',
content: 'please reload',
textAlign: 'left',
})
return
}
let msg = {
chat: 'group',
type: 'say',
content: chatContent.value,
is_img: false,
}
ws.send(msg)
chatContent.value = ''
}
//获取历史信息
const historyList = ref<any[]>([])
const getHistory = () => {
let params = {
limit: 100,
}
let ajax = getHistoryRecord(params).then((res) => {
if (res.code == 200) {
res.data.forEach((item) => {
if (item.from_user_id == store.getters.userInfo.user_id) {
item.sender = 'me'
}
})
historyList.value = res.data
}
})
return ajax
}
const sliceHistoryList = computed(() => {
return historyList.value.slice(sliceNum.value, sliceNum.value + 10)
})
//
const customTheme = {
'V3Emoji-hoverColor': '#303237',
'V3Emoji-activeColor': '#303237',
'V3Emoji-shadowColor': 'none',
'V3Emoji-backgroundColor': '#222428',
'V3Emoji-fontColor': '#ffffff',
}
const chatContent = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const handlePhoto = () => {
if (fileInput.value) {
fileInput.value.click()
}
}
// 相册图片发送
const handleFileChange = () => {
if (fileInput.value?.files) {
// 处理文件
const file = fileInput.value.files[0]
if (file.size > 5 * 1024 * 1024) {
$okToast.show({
type: 'warn',
content: 'The picture is too large',
textAlign: 'left',
})
return
}
const formData = new FormData()
formData.append('file', file)
load.show('Image uploading in progress...')
uploadImg(formData).then((res) => {
if (res.code == 200) {
let url = res.data
const charContent = {
chat: 'group',
type: 'say',
content: url,
is_img: true,
}
if (fileInput.value) fileInput.value.value = ''
ws.send(charContent)
load.hide()
} else {
load.hide()
}
})
// 发送formData到服务器 loadingDirective
}
}
const goBack = () => {
router.go(-1)
}
const scrollToBottom1 = () => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
const images = ref()
const imgShow = ref(false)
const imgIndex = ref(0)
const showMoreImg = (e, index) => {
imgShow.value = true
const newImages: any = []
chatList.value.map((item, indet) => {
if (item.is_img) {
newImages.push({
img: item.content,
indexs: indet,
})
}
})
images.value = newImages
imgIndex.value = index
const abs = images.value.findIndex(function (element) {
return element.indexs === index
})
imgIndex.value = abs
}
return {
...toRefs(state),
handleFileChange,
fileInput,
handlePhoto,
customTheme,
chatContent,
handleLink,
socket,
t,
chatList,
handleCancle,
sendMessage,
scrollToBottom,
newMessageNum,
sliceHistoryList,
chatContainer,
loadingMore,
goBack,
scrollToBottom1,
images,
imgIndex,
handleSubmit,
showMoreImg,
imgShow,
}
},
})
</script>
3.websocket.ts
import store from '@/store'
import { getChatRoomBind } from '@/api/chatApi'
let heartBeat, //心跳得定时器
serverHeartBeat, //服务器响应的定时器
beat_time = 50000, //心跳时间间隔
reconnectTimer,
reconnectNum = 3, //重连次数
reconnectFlag = true, //控制重连,一次一次来
beat_data = {
chat:"ping",
}
let client_id
let isAccident = 1
export default class WebSocketClient {
private ws: WebSocket | any;
private url;
private message_func;
constructor(url: string) {
this.url = url
this.initWebSocket(url)
}
initWebSocket(url) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
//重连之后需要再绑定
// if (reconnectNum < 3 && store.getters.token.access_token) {
// getChatRoomBind(store.state.chat.client_id)
// }
// 开始心跳
console.log('链接成功');
this.heartBeat(1)
reconnectTimer && clearTimeout(reconnectTimer)
};
this.ws.onmessage = (event) => {
let msg = JSON.parse(event.data)
if (msg.chat === 'pong') {
console.log('正常');
serverHeartBeat && clearTimeout(serverHeartBeat)
}
if (msg.type == 'connect' && reconnectNum < 3) {
client_id = msg.client_id
store.commit('setClientId', msg.client_id)
if (reconnectNum < 3 && store.getters.token.access_token) {
getChatRoomBind(client_id)
reconnectNum = 3
}
}
if (msg.chat !== 'pong') {
if (this.message_func) {
this.onMessage(this.message_func)
}
}
}
this.ws.onclose = () => {
console.log('断线,onclose');
if (store.getters.token.access_token && isAccident == 1) {
this.reconnect()
}
this.heartBeat(2)
};
this.ws.onerror = (error: Event) => {
if (store.getters.token.access_token) {
this.reconnect()
}
this.heartBeat(2)
};
}
public send(data: any) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.error('WebSocket未连接');
}
}
public close(accident = 1) {
console.log('断线,close');
this.heartBeat(2)
this.ws.close();
isAccident = accident
//是否是主动断开的,1意外,0主动
if (accident == 1) {
this.reconnect()
}
}
// 监听消息
onMessage(callback: (data: any) => void): void {
this.ws.onmessage = event => {
this.message_func = callback
//忽略心跳返回信息
let msg = JSON.parse(event.data)
if (msg.chat !== 'pong') {
callback(event.data);
}
if (msg.chat === 'pong') {
serverHeartBeat && clearTimeout(serverHeartBeat)
}
}
}
onOpen(callback: (data:any) => void): void{
this.ws?.addEventListener('open', (data) => {
callback(data)
})
}
heartBeat(opa = 1) {
// 是否开启心跳
if (opa == 1) {
heartBeat = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
console.log('心跳');
this.send(beat_data)
serverHeartBeat = setTimeout(() => {
//3秒内没收到消息,断开重连
this.close()
clearInterval(heartBeat)
}, 3000);
}
//
}, beat_time)
} else {
heartBeat && clearInterval(heartBeat)
serverHeartBeat && clearTimeout(serverHeartBeat)
}
}
reconnect() {
heartBeat && clearInterval(heartBeat)
serverHeartBeat && clearTimeout(serverHeartBeat)
if(!reconnectFlag) return
if (reconnectNum > 0 && reconnectFlag) {
reconnectTimer = setTimeout(() => {
console.log('重连',reconnectNum);
this.initWebSocket(this.url)
reconnectNum -= 1
reconnectFlag = true
}, 5000);
reconnectFlag = false
} else {
console.error('websocket error');
}
}
}
移动端css
.container {
height: 100vh;
display: flex;
flex-direction: column;
.header {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 99;
}
.content {
margin: 44px 0 60px 0;
flex: 1;
overflow-y: auto;
position: relative;
padding: 0 16px;
height: calc(100% - 104px);
*::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
.toBottom {
position: fixed;
display: flex;
align-items: center;
justify-content: center;
bottom: 65px;
left: 0;
height: 30px;
background: #1c1d1f;
width: 100%;
z-index: 5;
img {
height: 16px;
width: 16px;
margin-right: 10px;
}
.msg-num {
color: #2b99ff;
font-weight: bold;
margin-right: 10px;
}
}
.chat-item {
display: flex;
// flex-direction: row-reverse;
margin-bottom: 20px;
color: #999fa9;
font-size: 12px;
.message-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
img {
height: 100%;
width: 100%;
border-radius: 50%;
}
}
.message-content {
width: calc(100% - 34px);
padding: 0 10px;
float: right;
.message-username {
color: #fff;
}
.message-text {
font-size: 14px;
max-width: 100%;
word-wrap: break-word !important;
/* 当单词太长时自动换行 */
overflow-wrap: break-word !important;
/* 允许在单词内部换行 */
display: inline-block;
background: #1c1d1f;
border-radius: 2px;
margin-top: 10px;
padding: 10px 16px;
img {
width: 100%;
}
}
.massage-img {
max-width: 200px;
max-height: 200px;
display: inline-block;
img {
max-width: 200px;
max-height: 200px;
}
}
.message-time {
margin-left: 10px;
color: #51565c;
}
}
}
.chat-item:first-child {
margin-top: 10px;
}
.photoImg {
margin-right: 10px;
}
}
.footer {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
padding: 5px 10px;
background-color: #222428;
z-index: 99;
}
}
.emoji {
// :deep()
:deep(.emoji-textarea) {
textarea {
// background: red !important;
background: #151617;
border-radius: 2px;
border: 1px solid #2f3134;
height: 50px;
padding: 10px;
padding-right: 60px;
// overflow: hidden !important;
resize: none !important;
::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
:deep(textarea ::-webkit-scrollbar) {
width: 0 !important;
height: 0 !important;
}
.emoji-textarea-pollup-container {
bottom: 16px;
*::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
.pollup {
border-radius: 0px;
left: -200px !important;
bottom: 50px !important;
}
}
.emoji-textarea-open-btn {
margin-right: 16px;
// margin: 5px;
// bottom: 25px;
}
}
}
:deep(.van-cell) {
padding: 0 16px !important;
}
:deep(.van-field__control) {
height: 50px !important;
max-height: 70px !important;
overflow-y: auto;
padding: 10px 0 10px 0;
}
:deep(.van-cell:hover) {
border-color: #2b99ff !important;
}
:deep(.van-image-preview__image-wrap) {
text-align: center;
img {
width: 100%;
max-height: 100vh;
}
}