效果图
整体思路
通过列表中的type属性判断当前消息的类型
status 属性决定是哪一方发送的消息
每发送一条或接收到一条消息对列表进行push操作
图片我这里是写死宽度,让高度自适应,这样可以不裁剪图片
图片目前写的是单图片预览,想写多图片可以获取整个聊天列表,拿到type为image的列表将数据将图片路径push进去,就可以实现多图预览了
语音播放方面
使用 uni.createInnerAudioContext() 切换到后台不会继续播放
PS:
关于发送消息这一块,发消息走的后台接口,这样你才能不只是1对1
这里的 sendmessage 方法就是展示用的 如果消息走接口的情况下完全可以删掉,根据后台返回的字段改成自己所需就好了
使用uni-popup uni-icons
也可以自己搞个弹窗,吧icons换成image
代码
<template>
<view class="chat">
<view class="bottom">
<image src="/static/image/chat/recorder.png" @click="onRecorderchange" v-if="!isRecorder"></image>
<image src="/static/image/chat/input.png" @click="onRecorderchange" v-else></image>
<view class="onRecorder" v-if="isRecorder" @touchstart="onStart" @touchend="onEnd"
:id="inrecord ? 'inRecorder' : ''">
按住说话
</view>
<input type="text" placeholder="输入你想说的话" v-model="chat" confirm-type="send" @confirm="onSend" v-else>
<image src="/static/image/chat/more.png" @click="showPopup"></image>
</view>
<view class="card">
</view>
<view class="content">
<scroll-view scroll-y="true" :style="{height : scrollViewHeight + 'px'}" :scroll-into-view="poaMessgae">
<view class="block" v-for="(item,index) in list" :key="index">
<view class="time" v-if="index == 0 || item.time - list[index-1].time >= 300000">
<text>{{formatDate(item.time)}}</text>
</view>
<view class="list" :id="item.status == 'l' ? 'l' : 'r'">
<image :src="item.avatar" class="avatar"></image>
<text v-if="item.type == 'text'">{{item.message}}</text>
<image :src="item.message" v-if="item.type == 'image'" @click="onPreview(item.message)"
mode="widthFix"></image>
<view class="record" v-if="item.type == 'record'" @click="onPlay(item.src,index)">
<text>{{item.message}}</text> " <image src="/static/image/chat/record.png"></image>
</view>
</view>
</view>
<view id="poaMessgae"></view> <!-- 仅用于定位到消息最后一条 -->
</scroll-view>
</view>
<!-- 底部弹出层 -->
<uni-popup ref="popup" background-color="#fff">
<view class="image">
<view class="list" @click="chooseImage">
<uni-icons type="image-filled" size="26" color="#8183F2"></uni-icons>
<text>图片</text>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
export default {
data() {
return {
bgAudioManager: '', // 全局音频播放
websocket: true,
record: '', // 全局唯一录音管理区
inrecord: false, // 是否处于录音状态
chat: '', // 用户在聊天框中输入的内容
scrollViewHeight: '',
isRecorder: false, // 是否处于语音状态
// 聊天列表数据
// 头像其实可以写俩,你对面的,你自己的,我这里从简了 两端一样客服
list: [{
message: "这是一条虚拟消息",
status: 'l',
type: 'text',
avatar: '/static/avatar.png',
time: 1683791638972
},
{
message: "哈哈",
status: 'l',
type: 'text',
avatar: '/static/avatar.png',
time: 1683793245623
},
{
message: "https://ntg.a40.com.cn/uploads/20230505/69d7f7cefd4f36c2137d8d309d2b0c73.jpg",
status: 'l',
type: 'image',
avatar: '/static/avatar.png',
time: 1683793315399
},
{
message: "这是一条虚拟消息",
status: 'r',
type: 'text',
avatar: '/static/avatar.png',
time: 1683793764803
},
{
message: "10",
status: 'l',
type: 'record',
avatar: '/static/avatar.png',
scr: '音频链接',
time: 1683795488873
},
],
poaMessgae: 'poaMessgae',
}
},
methods: {
formatDate(value) {
if (typeof(value) == 'undefined') {
return ''
} else {
let date = new Date(value)
let now = new Date()
let y = date.getFullYear()
let MM = date.getMonth() + 1
MM = MM < 10 ? ('0' + MM) : MM
let d = date.getDate()
d = d < 10 ? ('0' + d) : d
let h = date.getHours()
h = h < 10 ? ('0' + h) : h
let m = date.getMinutes()
m = m < 10 ? ('0' + m) : m
let s = date.getSeconds()
s = s < 10 ? ('0' + s) : s
if (now.getDate()-d==1 && now - date < 172800000) {
return '昨天' + h + ':' + m
} else if (now - date < 86400000) {
return h + ':' + m
} else if (now - date >= 86400000 && now - date < 31536000000) {
return MM + '-' + d + ' ' + h + ':' + m
} else if (now - date >= 31536000000) {
return y + '-' + MM + '-' + d + ' ' + h + ':' + m
}
}
},
// input 输入框 回车发送消息事件
onSend() {
// 发送消息需要将 对象转换成字符串格式 接收消息在转换成对象
let mess = {
message: this.chat,
status: 'r',
type: 'text',
avatar: '/static/avatar.png',
time: new Date() - 0
}
this.sendmessage(JSON.stringify(mess))
this.chat = ''
},
// 定位到消息最后一行
poalast() {
let that = this
this.$nextTick(() => {
this.poaMessgae = ''
setTimeout(() => {
this.poaMessgae = 'poaMessgae'
}, 50)
})
},
// 获取指定元素高度 为了设置scroll-view的高度 否则 srollviewinto 会失效
getHeight(classNa) {
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select(classNa).boundingClientRect(data => {
// this.$emit('heightChange',data.height);
this.scrollViewHeight = data.height
}).exec();
query.select('.bottom').boundingClientRect(res => {
// this.$emit('heightChange',data.height);
this.scrollViewHeight = this.scrollViewHeight - res.height
}).exec();
}, 10);
},
// 切换语音 or 键盘
onRecorderchange() {
if (this.isRecorder) {
this.isRecorder = false
} else {
uni.authorize({
scope: 'scope.record',
success: res => {
this.isRecorder = true
}
})
}
},
// 长按开始录入语音
onStart() {
this.inrecord = true
this.record.start()
},
onEnd() {
console.log('结束录音');
this.inrecord = false
this.record.stop()
// 监听录音暂停的回调参数
this.record.onStop(res => {
console.log(res, "录音回调地址");
if (res.duration < 1000) {
return this.global.toast('请说话久一点')
}
// 将录音上传至服务器 拿到在线地址 连同地址一并发给客服 客服通过type来判断消息类型 实现播放
/*
后台上传步骤 后台莫得接口 省略先
*/
let mess = {
message: Math.round(res.duration / 1000), // 时长
status: 'r',
// type 区分消息类型
type: 'record',
src: res.tempFilePath,
avatar: '/static/avatar.png',
time: new Date() - 0
}
// 写在上传回调内
this.sendmessage(JSON.stringify(mess))
})
this.record.onError(err => {
this.global.toast('获取录音失败')
})
},
onPlay(e, index) {
// 我这里没有写暂停播放 需要的可以 设置变量 根据变量状态判断 pause 或 play
this.bgAudioManager = uni.createInnerAudioContext()
this.bgAudioManager.src = e
this.bgAudioManager.play()
this.bgAudioManager.onPlay(() => {
this.list[index].message--
})
this.bgAudioManager.onEnded(() => {
this.list[index].message = this.bgAudioManager.duration
this.bgAudioManager.offPlay()
this.bgAudioManager.offEnded()
this.bgAudioManager = null
})
},
// 预览图片
onPreview(e) {
let urls = []
urls.push(e)
uni.previewImage({
urls
})
},
showPopup() {
this.$refs.popup.open('bottom')
},
chooseImage() {
let that = this
uni.chooseImage({
count: 9,
sizeType: 'compressed',
success: file => {
file.tempFilePaths.forEach(item => {
that.imgToBase64(item, base => {
that.$post('api/index/upfileurl', {
image: base
}).then(res => {
if (res.data.code == 1) {
let mess = {
message: that.$IMG + res.data.data
.fileurl,
status: 'r',
type: 'image',
avatar: '/static/avatar.png',
time: new Date() - 0
}
that.sendmessage(JSON.stringify(mess))
}
this.$refs.popup.close()
})
})
})
}
})
},
initWebSocket() {
const url = 'wss:****'; // webSocket 域名
this.websock = uni.connectSocket({
url: url,
success() {
console.log('打开 websocket');
}
});
this.websock.onOpen(this.onopen)
this.websock.onMessage(this.onmessage)
this.websock.onClose(this.close)
this.websock.onError(this.onerror)
},
onopen() { // 连接建立之后执行send方法发送数据,连接成功
const data = {
token: this.userInfo.access_token
};
if (data.token) {
const result = JSON.stringify(data);
this.websock.send({
data: result
})
} else {
this.websock.close()
}
},
onmessage(e) { // 数据接收
e.status = 'l'
let message = JSON.parse(e);
this.list.push(message)
},
// 发送消息
sendmessage(e) {
this.list.push(JSON.parse(e))
this.poalast() // 定位消息最后一行
},
close(e) {
if (websocket) { // 非手动关闭自动从连
this.reconnect()
}
},
onerror() {
if (websocket) { // 非手动关闭自动从连
console.log('断开了重连');
this.reconnect()
}
},
reconnect() {
this.initWebSocket();
},
},
onLoad(option) {
this.getHeight('.chat')
// 全局唯一录音管理器
this.record = uni.getRecorderManager()
}
}
</script>
<style scoped lang="less">
.chat {
padding: 30rpx;
height: 100vh;
box-sizing: border-box;
}
.image {
background: #f6f6f6;
border-radius: 30rpx 30rpx 0 0;
display: flex;
align-items: center;
padding: 40rpx 80rpx;
height: 180rpx;
box-sizing: border-box;
.list {
display: flex;
align-items: center;
flex-direction: column;
margin-right: 10rpx;
text {
font-size: 26rpx;
color: #333333;
line-height: 30rpx;
margin-top: 10rpx;
}
}
}
.bottom {
position: fixed;
bottom: 0;
left: 0;
width: 750rpx;
height: 150rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 31rpx 40rpx;
box-sizing: border-box;
background: #FFF;
z-index: 99;
image {
width: 36rpx;
height: 36rpx;
}
input {
width: 581rpx;
height: 69rpx;
background: #F7F7F9;
border-radius: 40rpx 40rpx 40rpx 40rpx;
padding: 0pt 30rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.onRecorder {
width: 581rpx;
height: 69rpx;
line-height: 69rpx;
text-align: center;
background: #F7F7F9;
border-radius: 40rpx 40rpx 40rpx 40rpx;
padding: 0pt 30rpx;
font-size: 28rpx;
box-sizing: border-box;
}
#inRecorder {
background: #cdcdcd;
}
}
.content {
padding-bottom: 150rpx;
.time {
display: flex;
align-items: center;
flex-direction: column;
margin-bottom: 20rpx;
text {
background: rgba(0, 0, 0, 0.6);
color: #f6f6f6;
padding: 6rpx 10rpx;
border-radius: 10rpx;
font-size: 20rpx;
}
}
.list {
display: flex;
align-items: flex-start;
margin-bottom: 39rpx;
.avatar {
border-radius: 50%;
width: 70rpx;
height: 70rpx;
}
image {
width: 120rpx;
// height: 120rpx;
}
text {
font-size: 22rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
max-width: 520rpx;
}
.record {
display: flex;
align-items: center;
image {
width: 40rpx;
height: 40rpx;
}
}
}
#l {
flex-direction: row;
text {
min-height: 84rpx;
background: #F4F8FB;
border-radius: 0rpx 20rpx 20rpx 20rpx;
padding: 0 20rpx;
line-height: 84rpx;
color: #333;
}
.avatar {
margin-right: 18rpx;
}
.record {
min-height: 84rpx;
background: #F4F8FB;
border-radius: 0rpx 20rpx 20rpx 20rpx;
padding: 0 20rpx;
line-height: 84rpx;
color: #333;
}
}
#r {
flex-direction: row-reverse;
text {
min-height: 84rpx;
background: #6B72F6;
border-radius: 20rpx 0rpx 20rpx 20rpx;
padding: 0 20rpx;
line-height: 84rpx;
color: #FFF;
}
.avatar {
margin-left: 18rpx;
}
.record {
min-height: 84rpx;
background: #6B72F6;
border-radius: 20rpx 0rpx 20rpx 20rpx;
padding: 0 20rpx;
line-height: 84rpx;
color: #FFF;
}
}
}
</style>