效果图:
设计思路:
1、首先是页面布局,参考了微信,左边是会话列表,右边是具体消息
2、后台接口:
会话列表
{ "addTime": "2021-08-19T07:48:06.501Z", "clientType": 0, "content": "string", "deleted": true, "id": 0, "inservice": 0, "mobile": "string", "msgType": 0, // 0:文字 1:图片 -3:保持ws链接状态 "updateTime": "2021-08-19T07:48:06.501Z", "userid": 0, // 用户ID "username": "string", "viewed": 0 // 是否看过,看过取消红点 }
消息列表
{ "addTime": "2021-08-19T07:52:58.552Z", "clientType": 0, "content": "string", // 内容 "deleted": true, "fromUserid": 0, // 发消息者 "id": 0, "msgType": 0, "toUserid": 0, // 接受者 "updateTime": "2021-08-19T07:52:58.552Z", "viewed": 0 }
3、webSocket建立前后端通信,设置定时器发送msgType=-3保持连接
4、一些基本细节:
发消息者居右且绿色背景,接收者居左且白色背景;
图片大小缩放,可单击查看放大的图片;
聊天栏若有滚动条,滚动条置底,这里有个坑会详细讲下;
如果有历史消息,聊天栏置顶增加“查看更多消息”按钮;
页面:
<template>
<page-header-wrapper>
<div class="table-page-search-wrapper">
<a-form layout="inline">
<a-row :gutter="16">
<a-col :xl="6" :lg="8" :md="12" :sm="24">
<a-card :bordered="false" class="height-1000">
<a-table
:rowKey="(record, index) => index"
:dataSource="conversationForm"
:columns="columns"
:pagination="paginationFlag"
:customRow="click"
>
<span slot="serial" slot-scope="text, record, index">
<div>
<strong v-if="conversationFlagList[index]" class="unRead">·</strong>
<strong @click="selectChat(text, record, index)"> {{ text.username }}</strong>
<a style="float: right" @click="selectChat(text, record, index)"> {{ text.addTime }}</a>
</div>
<a @click="selectChat(text, record, index)">
<span v-if="text.msgType === 0">
{{
text.content ? (text.content.length > 20 ? text.content.slice(0, 20) : text.content) : ''
}}</span
>
<span v-if="text.msgType === 1">[ 图片 ]</span>
</a>
</span>
</a-table>
</a-card>
</a-col>
<a-col :xl="18" :lg="8" :md="12" :sm="24">
<div class="height-1000">
<a-card :bordered="false" class="topCard">
<div>
<strong>{{ this.userInfo.name ? this.userInfo.name : '' }}</strong>
<span>{{ ' ' + this.userInfo.mobile ? this.userInfo.mobile : '' }}</span>
<span>{{ ' ' + this.userInfo.from ? this.userInfo.from : '' }}</span>
</div>
</a-card>
<a-card :bordered="false" class="midCard" ref="midCard">
<div v-if="moreRecordsFlag" class="moreRecords">
<a-button @click="getMoreMsg">查看更多消息</a-button>
</div>
<div :key="index" v-for="(item, index) in messageList">
<a-form-item :class="parseInt(item.fromUserid) === userInfo.userId ? 'leftPart' : 'rightPart'">
<div :class="parseInt(item.fromUserid) === userInfo.userId ? '' : 'time'">
<span>{{ item.addTime }}</span>
</div>
<div class="chatDiv">
<span v-if="item.msgType === 0">{{ item.content }}</span>
<img
v-if="item.msgType === 1"
:src="item.content"
class="img"
@click="viewPicture(item.content)"
/>
</div>
</a-form-item>
</div>
</a-card>
<a-card :bordered="false" class="bottomCard">
<a-form-item v-show="userInfo.userId !== ''">
<a-textarea class="w100" v-model="form.response" placeholder="请输入回复内容" :rows="3" />
<a-button type="primary" style="float: right" @click="sendMsg">发送</a-button>
</a-form-item>
</a-card>
</div>
</a-col>
</a-row>
</a-form>
<a-modal v-model="handleVisible" :footer="null" width="600px" wrapClassName="modalBody">
<img :src="viewPictureSrc" class="largeImg" />
</a-modal>
</div>
</page-header-wrapper>
</template>
JS:
<script>
import { STable, Ellipsis } from '@/components'
import { msgList, conversationList } from '@/api/customerService'
const columns = [
{
scopedSlots: { customRender: 'serial' }
}]
export default {
name: 'CurrentChat',
components: {
STable,
Ellipsis
},
data() {
this.columns = columns
return {
// 大于10条显示分页
messageList: [],
timestamp: undefined,
moreRecordsFlag: false,
conversationForm: [],
conversationFlagList: [],
paginationFlag: false,
midCard: undefined,
clearTimeSet: undefined, // 定时器
handleVisible: false,
viewPictureSrc: undefined,
fromUserid: '',
userInfo: {
name: '',
mobile: '',
from: '',
userId: '',
clientType: ''
},
path: '',
socket: '',
// 查询参数
queryParam: {
saleSn: undefined,
consignee: undefined,
mobile: undefined,
saleStatus: undefined,
sort: 'add_time',
order: 'desc'
},
form: {
response: ''
},
params: {
clientType: 0,
limit: 10,
order: 'desc',
page: 1,
userId: undefined,
sort: 'add_time',
timestamp: undefined
}
}
},
mounted() {
this.getMsgList()
this.getConversationList()
// setInterval(() => {
// this.reConnect()
// }, 60000)
},
watch: {
messageList: function (val) {
if (val.length > 0) {
this.refresh()
}
}
},
methods: {
getMsgList() {
msgList(this.params).then(res => {
const { errno } = res
if (errno === 0) {
const tempMessageList = res.data && res.data.list ? res.data.list : []
this.moreRecordsFlag = true
if (tempMessageList.length === 0) {
this.moreRecordsFlag = false
}
tempMessageList.reverse()
this.messageList = tempMessageList.concat(this.messageList)
}
}).catch(err => {
console.log(err)
})
},
getMoreMsg() {
this.params.timestamp = new Date(this.messageList[0].addTime).getTime()
this.getMsgList()
},
async sendMsg() {
if (this.form.response.replace(/ /g, '').length === 0) {
this.$message.error('请勿回复空白')
return false
}
if (!this.isOnlineCurrUser(this.socket)) {
this.socket = ''
this.init()
this.$message.info('已离线,正在重连')
// this.socket = ''
// setInterval(this.init(), 1000)
// this.init()
this.$message.success('连接成功')
return
// this.sendMsg()
// this.socket.send(JSON.stringify(parmas))
} else {
const parmas = {
clientType: this.params.clientType,
content: this.form.response,
fromUserid: sessionStorage.getItem('adminId'),
msgType: 0,
toUserid: this.userInfo.userId
}
this.socket.send(JSON.stringify(parmas))
const yy = new Date().getFullYear()
const mm = new Date().getMonth() + 1
const dd = new Date().getDate()
const hh = new Date().getHours()
const mf = new Date().getMinutes() < 10 ? '0' + new Date().getMinutes() : new Date().getMinutes()
const time = yy + '-' + mm + '-' + dd + ' ' + hh + ':' + mf
parmas.addTime = time
this.messageList.push(parmas)
}
// await sendToUserId(parmas)
// this.params.timestamp = undefined
// msgList(this.params).then(res => {
// const { errno } = res
// if (errno === 0) {
// const tempMessageList = res.data && res.data.list ? res.data.list : []
// if (tempMessageList.length === 0) {
// this.moreRecordsFlag = false
// }
// this.messageList.push(tempMessageList[0])
// }
// }).catch(err => {
// console.log(err)
// })
this.form.response = undefined
},
getConversationList() {
const params = {
limit: 10,
order: 'desc',
page: 1,
sort: 'add_time'
}
conversationList(params).then(res => {
const { errno } = res
if (errno === 0) {
this.conversationForm = res.data && res.data.list ? res.data.list : []
this.conversationForm.forEach(e => {
this.conversationFlagList.push(e.inservice === 0 && e.viewed === 0)
})
this.paginationFlag = (res.data.total > 9)
}
}).catch(err => {
console.log(err)
})
},
reConnect() {
if (!this.isOnlineCurrUser(this.socket) && this.userInfo.userId !== '') {
this.socket = ''
this.init()
}
},
init: function () {
if (typeof (WebSocket) === 'undefined') {
this.$message.error('您的浏览器不支持socket')
} else {
// 实例化socket
this.path = 'wss://dev.tianmiwo.com/api/webSocket/' + this.userInfo.userId + '-' + sessionStorage.getItem('adminId')
this.socket = new WebSocket(this.path)
// 监听socket连接
this.socket.onopen = this.open
// 监听socket错误信息
this.socket.onerror = this.error
// 监听socket消息
this.socket.onmessage = this.getMessage
const parmas = {
clientType: this.params.clientType,
fromUserid: sessionStorage.getItem('adminId'),
msgType: -3,
toUserid: this.userInfo.userId
}
this.setTimer(parmas)
}
},
open: function () {
console.log('socket连接成功')
},
error: function () {
console.log('连接错误')
},
getMessage: function (msg) {
console.log('---------------------------')
// console.log(msg)
console.log(msg.data)
const yy = new Date().getFullYear()
const mm = new Date().getMonth() + 1
const dd = new Date().getDate()
const hh = new Date().getHours()
const mf = new Date().getMinutes() < 10 ? '0' + new Date().getMinutes() : new Date().getMinutes()
const time = yy + '-' + mm + '-' + dd + ' ' + hh + ':' + mf
const data = JSON.parse(msg.data)
if (data.content === 'pong') {
return
}
data.addTime = time
this.messageList.push(data)
console.log('---------------------------')
},
send: function () {
// this.socket.send(params)
},
close: function () {
console.log('socket已经关闭')
},
// 选择会话
click(record, index) {
return {
on: {
click: () => {
this.selectChat(record, index)
}
}
}
},
// 选择会话
selectChat(record, index) {
if (this.socket !== '') {
this.socket.onclose = this.close
this.socket.close()
this.clearTimer()
}
this.$set(this.conversationFlagList, index, false)
this.messageList = []
this.params.userId = record.userid
this.params.clientType = record.clientType
this.getMsgList()
this.userInfo.name = record.username
this.userInfo.mobile = '(' + (record.mobile ? record.mobile : '--') + ')'
this.userInfo.userId = record.userid
this.userInfo.clientType = record.clientType
switch (record.clientType) {
case 0:
this.userInfo.from = '来自会员端'
break
case 1:
this.userInfo.from = '来自管理端'
break
case 2:
this.userInfo.from = '来自收银台'
break
case 3:
this.userInfo.from = '来自后台'
break
}
// 初始化
this.init()
this.refresh()
},
// 滚动条置底
refresh() {
this.$nextTick(() => {
// this.midCard = this.$refs.midCard
// console.log(this.midCard.scrollHeight)
document.getElementsByClassName('midCard')[0].scrollTop = document.getElementsByClassName('midCard')[0].scrollHeight
// console.log(document.getElementsByClassName('midCard')[0].scrollTop)
// console.log(document.getElementsByClassName('midCard')[0].scrollHeight)
})
},
/**
* 判断当前用户是否 还在线
*/
isOnlineCurrUser(ws) {
if (ws) {
if (ws.readyState === WebSocket.OPEN) {
return true
} else {
return false
}
} else {
return false
}
},
// 设置定时器
setTimer(parmas) {
this.clearTimeSet = setInterval(() => {
this.socket.send(JSON.stringify(parmas))
}, 20000)
},
// 清除定时器
clearTimer() {
clearInterval(this.clearTimeSet)
},
// 查看大图
viewPicture(content) {
this.handleVisible = true
this.viewPictureSrc = content
}
},
destroyed() {
// 销毁监听
if (this.socket !== '') {
this.socket.onclose = this.close
this.socket.close()
// console.log('退出页面时关闭连接')
this.clearTimer()
}
}
}
</script>
样式:
<style lang="less" scoped>
.height-1000 {
min-height: 600px;
}
.topCard {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
}
.midCard {
height: 400px;
// 透明背景
background-color: rgba(255, 255, 255, 0.2);
overflow-y: auto;
.leftPart {
width: 100%;
.chatDiv {
padding: 0 10px 0 10px;
float: left;
// CSS3内容自适应
width: fit-content;
background-color: white;
border-radius: 5px;
}
}
.rightPart {
width: 100%;
.chatDiv {
padding: 0 10px 0 10px;
float: right;
width: fit-content;
background-color: #98e165;
border-radius: 5px;
}
}
}
.bottomCard {
height: 150px;
}
// div {
// border: 1px solid black;
// }
.time {
display: flex;
justify-content: flex-end;
background-color: rgba(255, 255, 255, 0);
}
.moreRecords {
display: flex;
justify-content: center;
.ant-btn {
border: 0;
background-color: rgba(255, 255, 255, 0);
color: cornflowerblue;
}
}
::v-deep .ant-table-thead {
display: none;
}
.unRead {
color: red;
left: -1px;
font-size: 30px;
line-height: 5px;
position: absolute;
}
.img {
width: 100px;
height: 100px;
}
.largeImg {
width: 600px;
height: 600px;
}
::v-deep .ant-modal-body {
padding: 0 !important;
font-size: 0 !important;
line-height: 1 !important;
}
关于滚动条置底
一开始想的比较简单,直接硬怼
// 滚动条置底
refresh() {
this.$nextTick(() => {
this.midCard = this.$refs.midCard
// console.log(this.midCard.scrollHeight)
this.midCard.scrollTop = this.midCard.scrollHeight
})
},
// 选择会话
selectChat(record, index) {
if (this.socket !== '') {
this.socket.onclose = this.close
this.socket.close()
this.clearTimer()
}
this.$set(this.conversationFlagList, index, false)
this.messageList = []
this.params.userId = record.userid
this.params.clientType = record.clientType
this.getMsgList()
this.userInfo.name = record.username
this.userInfo.mobile = '(' + (record.mobile ? record.mobile : '--') + ')'
this.userInfo.userId = record.userid
this.userInfo.clientType = record.clientType
switch (record.clientType) {
case 0:
this.userInfo.from = '来自会员端'
break
case 1:
this.userInfo.from = '来自管理端'
break
case 2:
this.userInfo.from = '来自收银台'
break
case 3:
this.userInfo.from = '来自后台'
break
}
// 初始化
this.init()
this.refresh()
},
后来发现this.midCard.scrollHeight始终是undefined,debug发现this.midCard设置滚动条时dom渲染还没有完成,所以只好为消息列表加上监听事件
watch: {
messageList: function (val) {
if (val.length > 0) {
this.refresh()
}
}
},
这里注意每次切换会话的时候清一下messageList免得发生一些意料之外的无法监听。
最后,this.midCard.scrollTop赋值居然不生效!非常怪,可能是封装的组件的问题?我用 document.getElementsByClassName('midCard')[0].scrollTop = document.getElementsByClassName('midCard')[0].scrollHeight这样解决的