# 一、 发语音界面开发
# 1. 初步开发:发语音和文字输入功能切换
在页面 /pages/chat/chat.nvue
<template>
<view>
<!-- 导航栏 -->
...
<!-- 聊天内容区域 -->
...
<!-- 底部聊天输入区域 -->
<view ...>
<view class="flex align-center">
<!-- 切换发语音 -->
<!-- 键盘图标 -->
<chat-navbar-icon-button v-if="sendMessageMode == 'audio'"
@click="changeAudioOrText('text')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<!-- 语音图标 -->
<chat-navbar-icon-button v-else
@click="changeAudioOrText('audio')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<view class="flex align-center font-sm
bg-white px-2 py-1 border rounded">
<!-- 按住发语音 -->
<view v-if="sendMessageMode == 'audio'"
style="width: 440rpx;height: 60rpx;"
class="flex align-center justify-center bg-white rounded">
<text class="font mr-2">按住</text>
<text class="font">说话</text>
</view>
<!-- 发文字 -->
<textarea v-else
...>
</textarea>
</view>
</view>
<view class="flex align-center">
...
</view>
</view>
</view>
</template>
<script>
export default {
...,
methods: {
// 切换文字输入或者语音
changeAudioOrText(sendMessageMode){
this.sendMessageMode = sendMessageMode;
},
...,
},
}
</script>
# 2. 按住录音状态界面开发
在页面 /pages/chat/chat.nvue
<template>
<view>
<!-- 导航栏 -->
...
<!-- 聊天内容区域 -->
...
<!-- 底部聊天输入区域 -->
<view ...>
<view class="flex align-center">
<!-- 切换发语音 -->
<!-- 键盘图标 -->
<chat-navbar-icon-button v-if="sendMessageMode == 'audio'"
@click="changeAudioOrText('text')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<!-- 语音图标 -->
<chat-navbar-icon-button v-else
@click="changeAudioOrText('audio')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<view class="flex align-center font-sm px-2 py-1 border rounded"
:class="[recorderStatus?'bg-hover-light':'bg-white',]">
<!-- 按住发语音 -->
<view v-if="sendMessageMode == 'audio'"
style="width: 440rpx;height: 60rpx;"
class="flex flex-row align-center justify-center rounded"
@touchstart="recorderTouchstart"
@touchend="recorderTouchend"
@touchcancel="recorderTouchcancel"
@touchmove="recorderTouchmove">
<text class="font mr-2" v-if="!recorderStatus">按住</text>
<text class="font" v-if="!recorderStatus">说话</text>
<text class="font mr-2" v-if="recorderStatus">松开</text>
<text class="font" v-if="recorderStatus">发送</text>
</view>
<!-- 发文字 -->
<textarea v-else
...>
</textarea>
</view>
</view>
<view class="flex align-center">
...
</view>
</view>
<!-- 弹出菜单 -->
...
<!-- 发语音页面提示 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;
background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg"></view>
</view>
</view>
</template>
<script>
...
export default {
...,
data(){
return {
recorderStatus: false, // 录音状态, 是否正在录音
...,
}
},
methods: {
// 手指按上去
recorderTouchstart(){
console.log('手指按上去');
this.recorderStatus = true;
},
// 手指松开
recorderTouchend(){
console.log('手指松开');
this.recorderStatus = false;
},
// 触摸取消,比如来电话终止
recorderTouchcancel(){
console.log('触摸取消');
this.recorderStatus = false;
},
// 手指滑动
recorderTouchmove(){
console.log('手指滑动');
},
...
},
}
</script>
# 3. 按住录音提示用户正在录音界面
正在录音素材: https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/recorder.gif
在页面 /pages/chat/chat.nvue
<!-- 提示用户正在录音的界面 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg flex flex-column align-center justify-center">
<image src="https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/recorder.gif" style="width: 300rpx;height: 300rpx;"></image>
<text class="font-sm text-white mt-1">正在录音 手指上划 取消发送</text>
</view>
</view>
# 4. 上移手指取消录音发送
在页面 /pages/chat/chat.nvue
<!-- 提示用户正在录音的界面 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg flex flex-column align-center justify-center">
<image :src="recorderIcon" style="width: 300rpx;height: 300rpx;"></image>
<text class="font-sm text-white mt-1">{{isRecorderCancel?'松开手指 取消发送':'正在录音 手指上划 取消发送'}}</text>
</view>
</view>
...
<script>
...
export default {
...,
data(){
return {
isRecorderCancel:false, // 是否取消录音
recorderTouchstartY:0, // 录音开始手指纵坐标
recorderIcon:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/recorder.gif',
...,
}
},
methods: {
// 手指按上去
recorderTouchstart(e){
this.recorderStatus = true;
// console.log('手指按上去',e.changedTouches[0].clientY);
// #ifdef MP || H5
this.recorderTouchstartY = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
this.recorderTouchstartY = e.changedTouches[0].screenY;
// #endif
},
// 手指松开了
recorderTouchend(){
console.log('手指松开了');
this.recorderStatus = false;
},
// 触摸取消 来点打断了 手机没电了
recorderTouchcancel(){
console.log('触摸取消');
this.recorderStatus = false;
},
// 手指移动
recorderTouchmove(e){
console.log('手指移动');
let y = 0;
// #ifdef MP || H5
y = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
y = e.changedTouches[0].screenY;
// #endif
// 判断手指上移 移出了录音按钮的区域
let move = Math.abs(y - this.recorderTouchstartY);
// console.log('移动的距离',move);
this.isRecorderCancel = move > 55 ? true : false;
},
...
},
}
</script>
# 5. 发语音功能实现
- uni-app中的api接口:https://uniapp.dcloud.net.cn/api/#媒体 (opens new window)
- 录音接口: https://uniapp.dcloud.net.cn/api/media/record-manager.html (opens new window)
在页面
/pages/chat/chat.nvue
<template>
<view>
<!-- 提示用户正在录音的界面 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg flex flex-column align-center justify-center">
<image :src="recorderIcon" style="width: 300rpx;height: 300rpx;"></image>
<text class="font-sm text-white mt-1">{{isRecorderCancel ?
'松开手指 取消发送' : '已录 ' + recorderDuration + ' 秒 手指上划 取消发送'}}</text>
</view>
</view>
</view>
</template>
<script>
// 获取全局唯一的录音管理器 recorderManager
const recorderManager = uni.getRecorderManager();
...
export default {
mixins:[toolJs],
data() {
return {
recorderDurationTimer:null, // 定时器
recorderDuration:0, // 针对app没有录音时长的处理
...
}
},
mounted() {
...
//监听录音结束 拿到音频内容
recorderManager.onStop(res=>{
if(this.recorderDurationTimer){
clearInterval(this.recorderDurationTimer);
this.recorderDurationTimer = null;
}
console.log('拿到音频内容',res);
if(!this.isRecorderCancel){
res.duration = this.recorderDuration * 1000;
this.sendMessage('audio',res);
}
});
// 监听录音开始
recorderManager.onStart(()=>{
this.recorderDuration = 0;
this.recorderDurationTimer = setInterval(()=>{
this.recorderDuration ++;
},1000);
});
},
methods: {
// 手指按上去
async recorderTouchstart(e){
// 查看录音权限情况
try{
const permission = new UniPermission();
const granted = await permission.requestPermission('microphone',
'需要您打开麦克风来录制语音','本功能需要您打开麦克风');
if(granted){
console.log('用户已授权开启麦克风,可以录音了');
this.recorderStatus = true;
// console.log('手指按上去',e.changedTouches[0].clientY);
// #ifdef MP || H5
this.recorderTouchstartY = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
this.recorderTouchstartY = e.changedTouches[0].screenY;
// #endif
// 可能正在录音来电话了被打断,此时的isRecorderCancel是true
this.isRecorderCancel = false;
// 开始录音处理
recorderManager.start({
duration:60000, // 毫秒
format:'mp3',
hideTips:true,
});
}else{
uni.showToast({
title: '您没有授权打开麦克风,无法录制语音',
icon:'none',
duration:3000
});
}
}catch(error){
console.error('权限申请异常:' + error);
uni.showToast({
title:'权限申请失败:' + error.message,
icon:'none',
duration:3000
});
}
},
// 手指松开了
recorderTouchend(){
console.log('手指松开了');
this.recorderStatus = false;
//停止录音
recorderManager.stop();
},
// 触摸取消 来点打断了 手机没电了
recorderTouchcancel(){
console.log('触摸取消');
this.recorderStatus = false;
this.isRecorderCancel = true;
//停止录音
recorderManager.stop();
},
// 手指移动
recorderTouchmove(e){
console.log('手指移动');
let y = 0;
// #ifdef MP || H5
y = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
y = e.changedTouches[0].screenY;
// #endif
// 判断手指上移 移出了录音按钮的区域
let move = Math.abs(y - this.recorderTouchstartY);
// console.log('移动的距离',move);
this.isRecorderCancel = move > 55 ? true : false;
},
...,
//发送消息
sendMessage(msgType, option = {}){
...
switch (msgType){
...
case 'audio':
console.log('audio的数据',option);
msg.data = option.tempFilePath;
msg.otherData = {
duration:Math.round(option.duration / 1000),
};
}
...
// 清空发送的内容然后还要滚动到底部
...
},
},
}
</script>
# 6. 优化发语音功能
重点就要理解:获取全局唯一的录音管理器 recorderManager 进行处理
很显然:
- 定义全局唯一的录音管理器: 放在了当前
/pages/chat/chat.nvue页面,如果其他页面也需要录音功能,那么这个定义就不唯一,更不是全局唯一 - 在页面上,我们在
mounted生命周期函数中,对录音管理器 recorderManager进行监听,我们应该执行全局监听事件
# 1. 在页面 /pages/chat/chat.nvue中
<template>
<view>
<!-- 导航栏 -->
...
<!-- 聊天内容区域 -->
...
<!-- 底部聊天输入区域 -->
...
<!-- 弹出菜单 -->
...
<!-- 提示用户正在录音的界面 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg flex flex-column align-center justify-center">
<image :src="recorderIcon" style="width: 300rpx;height: 300rpx;"></image>
<text class="font-sm text-white mt-1">{{isRecorderCancel ?
'松开手指 取消发送' : '已录 ' + recorderDuration + ' 秒 手指上划 取消发送'}}</text>
</view>
</view>
</view>
</template>
<script>
//获取全局唯一的录音管理器 recorderManager
//const recorderManager = uni.getRecorderManager();
...
import {mapState,mapGetters,mapMutations,mapActions} from 'vuex';
export default {
mixins:[toolJs],
data() {
return {
...
}
},
mounted() {
...
// 全局注册一个发送语音的事件,然后进行全局处理
this.regSendMessage(res=>{
if(!this.isRecorderCancel){
res.duration = this.recorderDuration * 1000;
this.sendMessage('audio',res);
}
});
},
computed:{
...mapState({
recorderManager:state=>state.Audio.recorderManager,
recorderDuration:state=>state.Audio.recorderDuration,
}),
...
},
methods: {
...mapMutations(['regSendMessage']),
// 手指按上去
async recorderTouchstart(e){
// 查看一下录音权限情况
try{
...
if(granted){
...
// 开始录音
this.recorderManager.start({
duration:60000, // 毫秒
format:'mp3',
});
}else{
...
}
}catch(error){
...
}
},
// 手指松开了
recorderTouchend(){
...
//停止录音
this.recorderManager.stop();
},
// 触摸取消 来点打断了 手机没电了
recorderTouchcancel(){
...
//停止录音
this.recorderManager.stop();
},
// 手指移动
recorderTouchmove(e){
...
},
...
},
}
</script>
# 2. 项目初始化执行 App.vue
...
<script>
export default {
onLaunch: function() {
console.log('App Launch')
...
// #ifdef APP-PLUS || MP
// 初始化录音管理器
this.$store.commit('initRecorderManager');
// #endif
},
onShow: function() {
...
},
onHide: function() {
...
}
}
</script>
# 3. 来到音频模块 /store/modules/audio.js
export default{
// 对应的mapState,在computed中引用导入
// 类似于data,把全局或者公共部分放在这里
state:{
...
// 全局唯一的录音管理器 recorderManager
recorderManager:null,
recorderDurationTimer:null, //定时器
recorderDuration:0, // 针对app没有录音时长的处理
sendMessage:null, // 发语音函数
},
// 同步的方法,在methods引入
mutations:{
// 初始化全局唯一的录音管理器 recorderManager
initRecorderManager(state){
console.log('初始化全局唯一的录音管理器');
state.recorderManager = uni.getRecorderManager();
// 监听录音结束 获取录音,
state.recorderManager.onStop(res=>{
if(state.recorderDurationTimer){
clearInterval(state.recorderDurationTimer);
state.recorderDurationTimer = null;
}
console.log('录音地址',res);
// if(!this.isRecorderCancel){
// res.duration = this.recorderDuration * 1000;
// this.sendMessage('audio',res);
// }
if(typeof state.sendMessage == 'function'){
state.sendMessage(res);
}
});
// 监听录音开始
state.recorderManager.onStart(()=>{
state.recorderDuration = 0;
state.recorderDurationTimer = setInterval(()=>{
state.recorderDuration ++;
},1000);
});
},
// 注册一个发送音频事件
regSendMessage(state,eventName){
state.sendMessage = eventName;
},
...
},
// 异步的方法,在methods引入
actions:{
...
}
}
# 7. 优化播放语音,语音播放完了图标还在动
关于音频播放的api查看:https://uniapp.dcloud.net.cn/api/media/audio-context.html (opens new window)
在组件 /components/chat-item-audio/chat-item-audio.vue 中添加如下代码
//播放语音
playAudio(item,index){
...
if(!this.innerAudioContext){
...
// 监听语音自然播放结束事件
this.innerAudioContext.onEnded(()=>{
this.audioPlayStatus = false;
});
}else{
...
}
},
# 8. 优化语音播放功能:正在播放的语音点击则停止播放,在点击则播放,而不是点击后暂停又播放
在组件 /components/chat-item-audio/chat-item-audio.vue 中代码
<template>
<view class="flex align-center" @click="toggleAudio(item,index)"
:class="[isMe?'justify-end':'justify-start']">
<view v-if="!isMe">
<image :src="audioPlayStatus ? audioIconList.notme.play:audioIconList.notme.stop"
style="width: 36rpx;height: 36rpx;"></image>
</view>
<text class="font"
:class="[isMe?'mr-1':'ml-1']">{{item.otherData.duration + "'"}}</text>
<!-- 我 音频 素材图片 -->
<view v-if="isMe">
<image :src="audioPlayStatus ? audioIconList.me.play :audioIconList.me.stop"
style="width: 36rpx;height: 36rpx;"></image>
</view>
</view>
</template>
<script>
import {mapState,mapGetters,mapMutations,mapActions} from 'vuex';
export default{
name:"chat-item-audio",
props:{
item:Object,
index:Number,
isMe:Boolean,
},
data(){
return {
// 音频上下文
innerAudioContext:null,
// 播放语音动画素材
audioIconList:{
me:{
stop:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-me-icon.png',
play:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-me-icon.gif'
},
notme:{
stop:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-friend-icon.png',
play:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-friend-icon.gif'
},
},
// 播放状态
audioPlayStatus:false,
// 记录当前播放位置
currentPosition:0,
}
},
computed:{
...mapState({
// testVuexValue:state=>state.testVuex,
testVuexValue:state=>state.Audio.testVuex,
}),
},
mounted() {
if(this.item.type == 'audio'){
// this.$onGlobalEvent(res=>{
// console.log('监听$emitEventName方法的消息',res);
// });
//this.$onGlobalEvent(this.onplayAudio);
uni.$on('onplayAudio',res=>{
// console.log('通过uni.$on接受的结果',res);
this.onplayAudio(res);
})
}
console.log('vuex中的state的值',this.testVuexValue);
},
destroyed() {
// this.$offEventName(this.onplayAudio);
uni.$off('onplayAudio');
//销毁音频
if(this.innerAudioContext){
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
},
methods:{
// 引入actions里面的方法
...mapActions(['$onGlobalEvent','$emitEventName','$offEventName']),
/*
// 全局监听播放语音的处理
onplayAudio(res){
// console.log('监听$emitEventName方法的消息',res);
if(this.innerAudioContext){
//停止非点击的音频
if(res != this.index){
this.innerAudioContext.stop();
}
}
},
//播放语音
playAudio(item,index){
// this.$emitEventName('执行一个全局事件传一个要播放的音频索引:' + index);
// return;
// 通知其它非点击音频停止播放
//this.$emitEventName(index);
uni.$emit('onplayAudio',{index:index});
if(!this.innerAudioContext){
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.src = item.data;
this.innerAudioContext.play();
// 监听语音播放
this.innerAudioContext.onPlay(()=>{
this.audioPlayStatus = true;
});
// 监听语音暂停
this.innerAudioContext.onPause(()=>{
this.audioPlayStatus = false;
});
// 监听语音停止
this.innerAudioContext.onStop(()=>{
this.audioPlayStatus = false;
});
// 监听语音错误
this.innerAudioContext.onError(()=>{
this.audioPlayStatus = false;
});
// 监听语音自然播放结束事件
this.innerAudioContext.onEnded(()=>{
this.audioPlayStatus = false;
});
}else{
this.innerAudioContext.stop();
this.innerAudioContext.play();
}
},
*/
// 全局监听播放语音的处理
onplayAudio(res){
if(this.innerAudioContext && res != this.index){
this.innerAudioContext.stop();
}
},
// 切换音频播放状态
toggleAudio(item,index){
// 如果当前正在播放,则停止
if(this.audioPlayStatus){
this.innerAudioContext.stop();
return;
}
// 通知其它非点击音频停止播放
uni.$emit('onplayAudio',{index:index});
if(!this.innerAudioContext){
this.createAudioContext(item);
} else {
// 恢复上次播放位置
this.innerAudioContext.seek(this.currentPosition);
this.innerAudioContext.play();
}
},
// 创建音频上下文
createAudioContext(item){
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.src = item.data;
// 监听语音播放
this.innerAudioContext.onPlay(()=>{
this.audioPlayStatus = true;
});
// 监听语音暂停
this.innerAudioContext.onPause(()=>{
this.audioPlayStatus = false;
});
// 监听语音停止
this.innerAudioContext.onStop(()=>{
this.audioPlayStatus = false;
// 记录停止位置
this.innerAudioContext.onTimeUpdate(() => {
this.currentPosition = this.innerAudioContext.currentTime;
});
});
// 监听语音错误
this.innerAudioContext.onError(()=>{
this.audioPlayStatus = false;
});
// 监听语音自然播放结束事件
this.innerAudioContext.onEnded(()=>{
this.audioPlayStatus = false;
this.currentPosition = 0; // 播放完成后重置位置
});
this.innerAudioContext.play();
},
},
}
</script>
<style>
/* #ifdef H5 */
@import '/common/css/common.nvue.vue.css';
/* #endif */
</style>
# 二、关于播放语音、发语音各页面组件完整代码
# 1. 页面 /pages/chat/chat.nvue
<template>
<view>
<!-- 导航栏 -->
<chat-navbar title="聊天" :fixed="true"
:showPlus="false" :showUser="false"
:showBack="true" navbarClass="bg-light"
:h5WeiXinNeedNavbar="h5WeiXinNeedNavbar">
<chat-navbar-icon-button slot="right"
@click="openMore" >
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
</chat-navbar>
<!-- 聊天内容区域 -->
<scroll-view scroll-y class="bg-light position-fixed left-0 right-0"
:style="chatContentStyle" :show-scrollbar="false" @scroll="onScroll"
:scroll-into-view="scrollIntoViewId" >
<!-- 对话部分 -->
<view v-for="(item,index) in chatDataList" :key="index"
:id="'chat-item-'+index">
<chat-item :item="item" :index="index"
:prevTime="index>0 ? chatDataList[index-1].chat_time : 0"
ref="chatItem" @previewImages="previewImages"></chat-item>
</view>
</scroll-view>
<!-- 针对我们的app端点击聊天区域授权加号扩展菜单 -->
<!-- #ifdef APP -->
<view v-if="sendMessageMode == 'plus' || sendMessageMode == 'icon'"
class="position-fixed left-0 right-0"
:style="chatContentStyle"
@click="scrollViewClick"></view>
<!-- #endif -->
<!-- 底部聊天输入区域 --><!-- 修改:添加ref获取textarea实例 -->
<view class="position-fixed bottom-0 border-top
flex flex-row align-center justify-between"
style="background-color: #f7f7f7;width: 750rpx;
min-height: 90rpx;max-height: 320rpx;
padding-top: 12rpx;"
:style="chatBottomStyle">
<view class="flex align-center">
<!-- 切换发语音 -->
<!-- 键盘图标 -->
<chat-navbar-icon-button v-if="sendMessageMode == 'audio'"
@click="changeAudioOrText('text')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<!-- 语音图标 -->
<chat-navbar-icon-button v-else
@click="changeAudioOrText('audio')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<view class="flex align-center font-sm px-2 py-1 border rounded"
:class="[recorderStatus ? 'bg-hover-light' : 'bg-white']">
<!-- 发语音 按住说话 -->
<view v-if="sendMessageMode == 'audio'"
class="flex flex-row align-center justify-center rounded"
style="width: 440rpx;height: 60rpx;"
@touchstart="recorderTouchstart"
@touchend="recorderTouchend"
@touchcancel="recorderTouchcancel"
@touchmove="recorderTouchmove"
>
<text class="font mr-2" v-if="!recorderStatus">按住</text>
<text class="font" v-if="!recorderStatus">说话</text>
<text class="font mr-2" v-if="recorderStatus">松开</text>
<text class="font" v-if="recorderStatus">发送</text>
</view>
<!-- 发文字 -->
<textarea v-else
ref="textarea" fixed auto-height :maxlength="-1"
style="width: 440rpx;min-height: 60rpx;
max-height: 274rpx;overflow-y: scroll;
text-align: justify;"
:adjust-position="false"
v-model="messageValue"
@focus="textareaFocus"
@input="onTextareaInput"
@blur="onTextareaBlur" ><!-- 新增:监听失焦事件 --><!-- 新增:监听输入事件 -->
</textarea>
</view>
</view>
<view class="flex align-center">
<chat-navbar-icon-button v-if="!messageValue"
@click="openIcon">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<chat-navbar-icon-button v-if="!messageValue"
@click="openPlus">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<view v-if="messageValue"
class="rounded bg-success px-2 py-1 mr-4"
hover-class="bg-hover-success"
@click="sendMessage('text')">
<text class="font text-white">发送</text>
</view>
</view>
</view>
<!-- 弹出菜单 --><!-- 主要修改区域 -->
<chat-tooltip ref="tooltipPlus" :mask="chatTooltipMask"
:maskTransparent="true" :isBottom="true"
:tooltipWidth="750"
:tooltipHeight="tooltipHeight"
transformOrigin = "center bottom"
tooltipClass ="bg-light border-0 rounded-0"
@hideTooltip="hideTooltip">
<view class="border-top border-light-secondary">
<!-- 表情菜单 -->
<swiper v-if="sendMessageMode === 'icon'"
:indicator-dots="groupedIconMenus.length > 1"
:duration="1000"
:style="tooltipPlusMenuStyle"
@change="handleSwiperChange"><!-- 添加分页切换事件 -->
<!-- 第一页:emoji表情(滚动显示) -->
<swiper-item v-if="emojiPageItems.length > 0">
<scroll-view scroll-y style="height: 100%;">
<view class="flex flex-row flex-wrap justify-start">
<view v-for="(item,itemIndex) in emojiPageItems"
:key="itemIndex"
class="flex flex-column justify-center align-center"
style="width: 12.5%; height: 120rpx; padding: 10rpx;"
@click="insertEmoji(item)">
<text style="font-size: 50rpx;">{{item.icon}}</text>
</view>
</view>
</scroll-view>
</swiper-item>
<!-- 其他类型表情分页显示 -->
<swiper-item v-for="(page,pageIndex) in otherPages"
:key="pageIndex">
<view class="flex flex-row flex-wrap justify-start">
<view v-for="(item,itemIndex) in page"
:key="pageIndex+itemIndex"
class="col-3 flex flex-column justify-center align-center"
style="height: 260rpx;"
@click="item ? swiperItemClick(item,itemIndex) : null"
><!-- 小程序添加空值检查 -->
<view class="bg-white rounded-lg flex flex-row
align-center justify-center mb-2"
style="width:120rpx;height: 120rpx;">
<u--image v-if="item.iconType == 'image'"
:src="item.icon" mode="aspectFit"
width="120rpx" height="120rpx" radius="0rpx"></u--image>
<text v-if="item.iconType == 'emoji'"
style="font-size: 40px;color: #222222;">{{item.icon}}</text>
<text v-if="item.iconType == 'custom'"
class="iconfont"
style="font-size: 26px;color: #222222;">{{item.icon}}</text>
<u-icon v-if="item.iconType == 'uview'"
:name="item.icon" color="#222222" size="26"></u-icon>
</view>
<text class="font-sm text-light-muted">{{item.name}}</text>
</view>
</view>
</swiper-item>
</swiper>
<!-- 加号菜单(保持不变) -->
<swiper v-else-if="sendMessageMode === 'plus'"
:indicator-dots="pageCount > 1" :duration="1000"
:style="tooltipPlusMenuStyle">
<swiper-item v-for="(page,index) in groupedPlusMenus" :key="index">
<view class="flex flex-row justify-start flex-wrap"
:style="swiperItemStyle">
<view class="col-3 flex flex-column justify-center align-center"
style="height: 260rpx;"
v-for="(item,itemIndex) in page" :key="itemIndex"
@click="swiperItemClick(item,itemIndex)">
<view class="bg-white rounded-lg
flex flex-row align-center justify-center mb-2"
style="width:120rpx;height: 120rpx;">
<u--image v-if="item.iconType == 'image'"
:src="item.icon" mode="aspectFit"
width="120rpx" height="120rpx" radius="0rpx"></u--image>
<text v-if="item.iconType == 'emoji'"
style="font-size: 40px;color: #222222;">{{item.icon}}</text>
<text v-if="item.iconType == 'custom'"
class="iconfont"
style="font-size: 26px;color: #222222;">{{item.icon}}</text>
<u-icon v-if="item.iconType == 'uview'"
:name="item.icon" color="#222222" size="26"></u-icon>
</view>
<text class="font-sm text-light-muted">{{item.name}}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>
</chat-tooltip>
<!-- 提示用户正在录音的界面 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg flex flex-column align-center justify-center">
<image :src="recorderIcon" style="width: 300rpx;height: 300rpx;"></image>
<text class="font-sm text-white mt-1">{{isRecorderCancel ? '松开手指 取消发送'
: '已录' + recorderDuration + '秒 手指上划 取消发送'}}</text>
</view>
</view>
</view>
</template>
<script>
import toolJs from '@/common/mixins/tool.js';
import UniPermission from '@/common/mixins/uni_permission.js';
import {mapState,mapGetters,mapMutations,mapActions} from 'vuex';
export default {
mixins:[toolJs],
data() {
return {
isRecorderCancel:false, // 是否取消录音发送
recorderTouchstartY:0, // 录音开始手指纵坐标
recorderStatus:false, // 是否正在录音
cursorPos: 0, // 新增:记录textarea光标位置
h5WeiXinNeedNavbar:false, // h5端微信上是否需要导航栏
statusBarHeight:0,//状态栏高度动态计算
fixedHeight:0, //占位:状态栏+导航栏
bottomSafeAreaHeight:0, // 底部安全距离
KeyboardHeight:0, //键盘高度
scrollIntoViewId:'', // 滚动到指定的元素id
messageValue:'', // 发送的内容信息
tooltipHeight:600, // 加号弹出菜单的高度rpx
sendMessageMode:"text",//发送消息的情况:文字|语音|加号|表情
// #ifdef MP || H5
chatTooltipMask:true,
// #endif
// #ifdef APP
chatTooltipMask:false,
// #endif
recorderIcon:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/recorder.gif',
iconMenus:[
{ name:"微笑", icon:"/static/tabbar/index.png",
iconType:"image", eventType:"smile" },
{ name:"嘿嘿", icon:"😀",
iconType:"emoji", eventType:"heihei" },
{ name: "嗯,哼", icon: "https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/iconMenus/en.gif", iconType: "image", eventType: "enheng" },
{ name:"嘻嘻", icon:"😁",
iconType:"emoji", eventType:"xixi" },
{ name:"笑哭了", icon:"😂",
iconType:"emoji", eventType:"xiaokule" },
{ name:"哈哈", icon:"😃",
iconType:"emoji", eventType:"haha" },
{ name:"大笑", icon:"😄",
iconType:"emoji", eventType:"daxiao" },
{ name:"苦笑", icon:"😅",
iconType:"emoji", eventType:"kuxiao" },
{ name:"斜眼笑", icon:"😆",
iconType:"emoji", eventType:"xieyanxiao" },
{ name:"微笑天使", icon:"😇",
iconType:"emoji", eventType:"weixiaotianshi" },
{ name:"眨眼", icon:"😉",
iconType:"emoji", eventType:"zhayan" },
{ name:"羞涩微笑", icon:"😊",
iconType:"emoji", eventType:"xiuseweixiao" },
{ name:"呵呵", icon:"🙂",
iconType:"emoji", eventType:"hehe" },
{ name:"倒脸", icon:"🙃",
iconType:"emoji", eventType:"daolian" },
{ name:"笑得满地打滚", icon:"🤣",
iconType:"emoji", eventType:"xiaodemandidagun" },
],
plusMenus:[ // 加号扩展菜单栏目
{ name:"照片", icon:"photo", iconType:"uview", eventType:"photo" },
{ name:"位置", icon:"map", iconType:"uview", eventType:"map" },
{ name:"拍摄", icon:"\ue62c", iconType:"custom", eventType:"camera" },
{ name:"我的名片", icon:"\ue69d", iconType:"custom", eventType:"mingpian" },
{ name:"视频", icon:"\ue66d", iconType:"custom", eventType:"video" },
// { name:"拍摄", icon:"\ue62c", iconType:"custom", eventType:"camera" },
// { name:"我的名片", icon:"\ue69d", iconType:"custom", eventType:"mingpian" },
// { name:"视频", icon:"\ue66d", iconType:"custom", eventType:"video" },
// { name:"拍摄", icon:"\ue62c", iconType:"custom", eventType:"camera" },
// { name:"我的名片", icon:"\ue69d", iconType:"custom", eventType:"mingpian" },
// { name:"视频", icon:"\ue66d", iconType:"custom", eventType:"video" },
],
chatDataList:[
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-06.png',
nickname: '彦祖',
chat_time: 1750148439,
data: '老师你好,我想咨询一下本季课程,如果我不学习上一个季度,可以直接学习本季度吗?',
user_id: 1,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
nickname: '小二哥',
chat_time: 1750148449,
data: '同学你好,如果不学习上一个季度课程,如果你有vue的基础和js的基础知识,也可以学习本季度课程',
user_id: 2,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-06.png',
nickname: '彦祖',
chat_time: 1750148759,
data: '好的,我了解了,谢谢老师',
user_id: 1,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
nickname: '小二哥',
chat_time: 1750148859,
data: '不用谢',
user_id: 2,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-06.png',
nickname: '彦祖',
chat_time: 1750148879,
data: 'ok',
user_id: 1,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-06.png',
nickname: '彦祖',
chat_time: 1750148879,
data: '哈哈哈',
user_id: 1,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-06.png',
nickname: '彦祖',
chat_time: 1750148879,
data: '嗯啦',
user_id: 1,
type:'text', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
nickname: '小二哥',
chat_time: 1750148889,
data: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/kalong.mp3',
otherData:{
duration:60, // 单位秒
},
user_id: 2,
type:'audio', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
nickname: '小二哥',
chat_time: 1750148899,
data: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/weixinYuYing.mp3',
otherData:{
duration:3, // 单位秒
},
user_id: 2,
type:'audio', //image,video
isremove:false,
},
{
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-06.png',
nickname: '彦祖',
chat_time: 1750148999,
data: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/kalong.mp3',
otherData:{
duration:43, // 单位秒
},
user_id: 1,
type:'audio', //image,video
isremove:false,
},
],
}
},
mounted() {
let info = uni.getSystemInfoSync();
this.statusBarHeight = info.statusBarHeight;
this.fixedHeight = this.statusBarHeight + uni.upx2px(90);
this.bottomSafeAreaHeight = info.safeAreaInsets.bottom;
// 监听键盘高度变化
uni.onKeyboardHeightChange(res=>{
console.log('键盘高度变化',res);
// #ifdef H5 || MP
this.KeyboardHeight = res.height;
// #endif
// #ifdef APP
console.log('此时的输入模式', this.sendMessageMode);
if(this.sendMessageMode != 'plus' && this.sendMessageMode != 'icon'){
this.KeyboardHeight = res.height;
}
// #endif
if(this.KeyboardHeight){
this.chatContentToBottom();
}
});
// 页面加载完了之后就应该滚动到底部
this.$nextTick(()=>{
this.chatContentToBottom();
});
// 全局监听 全局注册一个发送语音的事件,然后全局
this.regSendMessage(res=>{
if(!this.isRecorderCancel){
res.duration = this.recorderDuration * 1000;
this.sendMessage('audio',res);
}
});
},
computed:{
...mapState({
recorderManager:state=>state.Audio.recorderManager,
recorderDuration:state=>state.Audio.recorderDuration,
}),
chatContentStyle(){
let pbottom = this.bottomSafeAreaHeight == 0 ?
uni.upx2px(12) : this.bottomSafeAreaHeight;
let bottom = pbottom + uni.upx2px(90 + 12) + this.KeyboardHeight;
//如果是h5端用微信打开并且不需要导航栏的时候
if(this.isWeixinBrowser() && !this.h5WeiXinNeedNavbar){
this.fixedHeight = this.statusBarHeight + 0;
uni.setNavigationBarTitle({
title:'阿祖'
});
}
// #ifdef APP || MP
if(this.KeyboardHeight){
bottom = uni.upx2px(90 + 12 + 12) + this.KeyboardHeight;
}
// #endif
return `top:${this.fixedHeight}px;bottom:${bottom}px;`;
},
chatBottomStyle(){
let pbottom = this.bottomSafeAreaHeight == 0 ?
uni.upx2px(12) : this.bottomSafeAreaHeight;
// #ifdef APP || MP
if(this.KeyboardHeight){
pbottom = uni.upx2px(12);
}
// #endif
return `padding-bottom: ${pbottom}px;bottom:${this.KeyboardHeight}px;`;
},
//加号菜单每页的滑动样式
tooltipPlusMenuStyle(){
let pbottom = 0;
let height = uni.upx2px(this.tooltipHeight - 1) - pbottom;
// #ifdef APP || MP
pbottom = this.bottomSafeAreaHeight;
// #endif
return `padding-bottom:${pbottom}px;height:${height}px;`;
},
//加号菜单每页的布局样式
swiperItemStyle(){
let pbottom = 0;
let height = uni.upx2px(this.tooltipHeight - 1) - pbottom;
return `padding-bottom:${pbottom}px;height:${height}px;`;
},
// 加号菜单分页
// groupedPlusMenus(){
// const perPage = 8; // 每页8个
// const result = [];
// // 将数组plusMenus或者iconMenus每页8个分组
// for(let i=0;i<this.tooltipPlusMenusOrIconMenus.length; i += perPage){
// result.push(this.tooltipPlusMenusOrIconMenus.slice(i, i + perPage))
// }
// return result;
// },
//计算总页数
// pageCount(){
// return Math.ceil(this.tooltipPlusMenusOrIconMenus.length / 8);
// },
// 扩展菜单或者表情包数据源
tooltipPlusMenusOrIconMenus(){
if(this.sendMessageMode == 'plus' || this.sendMessageMode == 'icon'){
return this[`${this.sendMessageMode}Menus`]
}
return [];
},
// 修改:加号菜单分页计算
groupedPlusMenus() {
const perPage = 8; // 每页8个(2行)
const result = [];
for (let i = 0; i < this.plusMenus.length; i += perPage) {
result.push(this.plusMenus.slice(i, i + perPage));
}
return result;
},
// 修改:计算总页数(分别处理两种菜单)
pageCount() {
if (this.sendMessageMode === 'plus') {
return Math.ceil(this.plusMenus.length / 8);
} else if (this.sendMessageMode === 'icon') {
return this.groupedIconMenus.length;
}
return 0;
},
// 新增:表情菜单计算属性
emojiList() {
return this.iconMenus.filter(item => item.iconType === 'emoji');
},
otherList() {
return this.iconMenus.filter(item => item.iconType !== 'emoji');
},
emojiPageItems() {
return this.emojiList; // 所有emoji表情放在第一页
},
otherPages() {
const perPage = 8; // 每页8个(2行)
const pages = [];
for (let i = 0; i < this.otherList.length; i += perPage) {
pages.push(this.otherList.slice(i, i + perPage));
}
return pages;
},
groupedIconMenus() {
// 总页数 = 1 (emoji页) + 其他类型页数
const pages = [];
if (this.emojiPageItems.length > 0) {
pages.push(this.emojiPageItems); // 第一页放emoji
}
return pages.concat(this.otherPages);
},
// 多图预览图片地址的数组集合
previewImagesList(){
let arr = [];
this.chatDataList.forEach(item=>{
if(item.type == 'image' || (item.type == 'iconMenus' &&
item.dataType && item.dataType == 'image')){
arr.push(item.data);
}
});
return arr;
},
},
methods: {
...mapMutations(['regSendMessage']),
// 手指按上去
async recorderTouchstart(e){
// 查看一下录音权限情况
try{
const permission = new UniPermission();
const granted = await permission.requestPermission('microphone',
'需要您打开麦克风来录制语音','本功能需要您打开麦克风');
if(granted){
console.log('用户已授权开启麦克风,可以录音了');
console.log('手指按上去',e);
this.recorderStatus = true;
// #ifdef H5 || MP
this.recorderTouchstartY = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
this.recorderTouchstartY = e.changedTouches[0].screenY;
// #endif
// 可能正在录音来电话了 isRecorderCancel为true,要改成false
this.isRecorderCancel = false;
// 开始录音
this.recorderManager.start({
duration:60000, // 毫秒
format:'mp3',
});
}else{
uni.showToast({
title: '您没有授权打开麦克风,无法录制语音',
icon:'none',
duration:3000
});
}
}catch(error){
console.error('权限申请异常:' + error);
uni.showToast({
title:'权限申请失败:' + error.message,
icon:'none',
duration:3000
});
}
},
// 手指松开了
recorderTouchend(){
console.log('手指松开了');
this.recorderStatus = false;
//停止录音
this.recorderManager.stop();
},
// 触摸取消 来点打断了 手机没电了
recorderTouchcancel(){
console.log('触摸取消');
this.recorderStatus = false;
this.isRecorderCancel = true;
//停止录音
this.recorderManager.stop();
},
// 手指移动
recorderTouchmove(e){
console.log('手指移动',e);
let y = 0;
// #ifdef H5 || MP
y = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
y = e.changedTouches[0].screenY;
// #endif
// 判断手指上移 移出录音区域的距离
let move = Math.abs(y - this.recorderTouchstartY);
console.log('手指上移距离',move);
this.isRecorderCancel = move > 55 ? true : false;
},
// 切换文字输入或者语音
changeAudioOrText(sendMessageMode){
this.sendMessageMode = sendMessageMode;
},
// 新增:textarea输入事件记录光标位置
onTextareaInput(e) {
// #ifdef H5 || APP
this.cursorPos = e.detail.cursor;
// #endif
},
// 新增:textarea失焦事件记录光标位置
onTextareaBlur(e) {
this.cursorPos = e.detail.cursor || this.messageValue.length;
},
// 新增:插入表情到textarea
insertEmoji(item) {
if (!item || !item.icon) return;
const emoji = item.icon;
const text = this.messageValue || '';
// 插入到当前光标位置
const newText = text.substring(0, this.cursorPos) +
emoji +
text.substring(this.cursorPos);
this.messageValue = newText;
// 更新光标位置(在插入的表情后面)
const newCursorPos = this.cursorPos + emoji.length;
this.cursorPos = newCursorPos;
// 设置光标位置(H5/APP支持)
this.$nextTick(() => {
if (!this.$refs.textarea) return;
let textarea;
// 处理 H5 平台的特殊情况
// #ifdef H5
textarea = this.$refs.textarea.$el; // 获取原生 DOM 元素
// #endif
// #ifndef H5
textarea = this.$refs.textarea;
// #endif
if (textarea) {
// 尝试设置光标位置
if (typeof textarea.setSelectionRange === 'function') {
try {
textarea.setSelectionRange(newCursorPos, newCursorPos);
} catch (e) {
console.warn('设置光标位置失败', e);
}
}
// 确保输入框聚焦
if (typeof textarea.focus === 'function') {
try {
textarea.focus();
} catch (e) {
console.warn('聚焦输入框失败', e);
}
}
}
});
},
//点击加号扩展菜单的某一项
// swiperItemClick(item,itemIndex){
// if(this.sendMessageMode == 'icon'){
// console.log('点击了表情包里面的某个表情');
// // this.messageValue += `[${item.name}]`;
// this.sendMessage('iconMenus',item)
// }else{
// console.log('点击加号扩展菜单的某一项',item.eventType);
// switch (item.eventType){
// case 'phote':
// break;
// case 'map':
// break;
// case 'camera':
// break;
// case 'mingpian':
// break;
// case 'video':
// break;
// }
// }
// },
// 修改:点击菜单项处理
async swiperItemClick(item, itemIndex) {
console.log('点击菜单项处理',item);
if (!item) return; // 防止undefined错误
if (this.sendMessageMode === 'icon') {
if (item.iconType === 'emoji') {
this.insertEmoji(item); // emoji插入输入框
} else {
this.sendMessage('iconMenus', item); // 其他类型直接发送
}
} else {
console.log('点击加号扩展菜单的某一项',item.eventType);
switch (item.eventType){
case 'photo':
await this.handlePhoto();
break;
case 'map':
break;
case 'camera':
break;
case 'mingpian':
break;
case 'video':
break;
}
}
},
// 发图片
async handlePhoto(){
try{
const permission = new UniPermission();
const granted = await permission.requestPermission('photo',
'需要访问您的相册来选择图片','本功能需要您打开相册');
if(granted){
console.log('用户已授权开启相册,可以选择照片了');
this.chooseImage();
}else{
uni.showToast({
title: '您没有授权开启相册,无法发图片',
icon:'none',
duration:3000
});
}
}catch(error){
console.error('权限申请异常:' + error);
uni.showToast({
title:'权限申请失败:' + error.message,
icon:'none',
duration:3000
});
}
},
//选择照片发送
chooseImage(){
uni.chooseImage({
count:9,
//sizeType:['original','compressed'],
sourceType:['album'],
success: (res) => {
console.log('选择照片res',res);
if(res.tempFilePaths && res.tempFilePaths.length){
// 发送到服务器或者第三方云存储
// 页面效果渲染效果
//单张
//this.sendMessage('image',{path:res.tempFilePaths[0]});
// 多张
res.tempFilePaths.forEach(item=>{
this.sendMessage('image',{path:item});
});
}
},
fail: (err) => {
console.error('选择图片失败:', err);
let errorMsg = '选择图片失败';
if(err.errMsg.includes('permission')){
errorMsg = '相册访问权限不足';
}else if(err.errMsg.includes('cancel')){
return; // 用户不授权不提示
}
uni.showToast({
title:errorMsg,icon:'none',duration:3000
});
}
});
},
//预览多张图片
previewImages(e){
console.log('预览多张图片在页面',e);
uni.previewImage({
urls:this.previewImagesList,
current:e.item.data,
indicator:'default',
});
},
handleSwiperChange(e) {
console.log('分页切换', e.detail.current);
// 可以在这里处理分页切换逻辑
},
//点击聊天区域
scrollViewClick(){
// #ifdef APP
console.log('点击聊天区域');
this.KeyboardHeight = 0;
uni.hideKeyboard();
this.$refs.tooltipPlus.hide();
this.sendMessageMode = "text";
// #endif
},
// 文本输入框聚焦
textareaFocus(){
// #ifdef APP
this.sendMessageMode = "text";
// #endif
},
//点击笑脸图标
openIcon(){
console.log('点击了笑脸');
// #ifdef APP
this.sendMessageMode = "icon";
uni.hideKeyboard();
// #endif
// #ifdef H5 || MP
this.sendMessageMode = "icon";
// #endif
this.$refs.tooltipPlus.show();
// #ifdef APP || MP || H5
this.KeyboardHeight = uni.upx2px(this.tooltipHeight);
this.chatContentToBottom();
// #endif
},
//点击了加号
openPlus(){
console.log('点击了加号');
// #ifdef APP
this.sendMessageMode = "plus";
uni.hideKeyboard();
// #endif
// #ifdef H5 || MP
this.sendMessageMode = "plus";
// #endif
this.$refs.tooltipPlus.show();
// #ifdef APP || MP || H5
this.KeyboardHeight = uni.upx2px(this.tooltipHeight);
this.chatContentToBottom();
// #endif
},
// 弹出框隐藏了
hideTooltip(){
console.log('弹出框隐藏了');
// #ifdef APP || MP || H5
this.KeyboardHeight = 0;
this.chatContentToBottom();
// #endif
},
//发送消息
sendMessage(msgType, option = {}){
console.log('发送消息',msgType);
let msg = {
avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
nickname: '小二哥',
user_id: 2,
chat_time: (new Date()).getTime(),
data: '',
type:msgType, //image,video
isremove:false,
};
switch (msgType){
case 'text':
msg.data = this.messageValue;
break;
case 'iconMenus':
console.log('iconMenus的数据',option);
msg.data = option.icon;
msg.dataType = option.iconType;
break;
case 'image':
console.log('image的数据',option);
msg.data = option.path;
break;
case 'audio':
console.log('audio的数据',option);
msg.data = option.tempFilePath;
msg.otherData = {
duration: Math.round(option.duration / 1000),
}
break;
}
this.chatDataList.push(msg);
// 清空发送的内容然后还要滚动到底部
if(msgType == 'text') this.messageValue = '';
this.chatContentToBottom();
},
openMore(){
console.log('点击了三个点图标');
},
//聊天内容滚到到底部
chatContentToBottom(){
// #ifdef APP
let chatItems = this.$refs.chatItem;
let lastIndex = chatItems.length - 1 == 0 ? 0 : chatItems.length - 1;
let last = chatItems[lastIndex];
const dom = weex.requireModule('dom');
dom.scrollToElement(last, {});
// #endif
// #ifdef MP || H5
if(this.chatDataList.length == 0) return;
const lastIndex = this.chatDataList.length - 1;
this.scrollIntoViewId = `chat-item-${lastIndex}`;
setTimeout(()=>{
this.scrollIntoViewId = '';
this.$nextTick(()=>{
this.scrollIntoViewId = `chat-item-${lastIndex}`;
});
},100)
// #endif
},
onScroll(){
console.log('页面发生了滚动');
}
},
watch:{
// 监听聊天记录数据变化,自动滚动到底部
chatDataList:{
handler(){
this.$nextTick(()=>{
this.chatContentToBottom();
});
},
deep:true
},
sendMessageMode(newVal,oldVal){
// #ifdef APP
console.log('监听发送模式',newVal);
if(newVal != 'plus' && newVal != 'icon'){
this.$refs.tooltipPlus.hide();
}
// #endif
},
},
}
</script>
<style>
/* #ifdef H5 */
@import '/common/css/common.nvue.vue.css';
/* #endif */
</style>
# 2. 组件 /pages/chat-item/chat-item.vue
<template>
<view class="px-3">
<!-- 时间 -->
<view v-if="chatShowTime"
class="flex align-center justify-center pt-2 pb-2">
<text class="font-sm text-light-muted">{{chatShowTime}}</text>
</view>
<!-- 撤回消息 -->
<view v-if="item.isremove"
class="flex align-center justify-center pt-2 pb-2">
<text class="font-sm text-light-muted">您撤回了一条信息</text>
</view>
<!-- 聊天内容 -->
<view v-else
class="flex align-start mb-3 position-relative"
:class="[!isMe ? 'justify-start' : 'justify-end']">
<!-- 好友 -->
<!-- 头像 -->
<u--image v-if="!isMe"
:src="item.avatar"
mode="widthFix"
width="80rpx" height="80rpx" radius="10rpx"></u--image>
<!-- 气泡 -->
<!-- 三角形 -->
<text v-if="!isMe && needQipaoClass"
class="iconfont font-md chat-left-icon"></text>
<!-- 内容 -->
<view class="p-2 rounded"
style="max-width: 500rpx;"
:style="contentStyle"
:class="[!isMe ? 'ml-1':'mr-1',`chatItem${index}`,
!isMe && needQipaoClass ? 'chat-left-content-bg pt-2' : 'pt-0',
isMe && needQipaoClass ? 'chat-right-content-bg pt-2' : 'pt-0',]"
:ref="'chatItem' + index"
@longpress="onLongpress($event,index,item)">
<!-- 情况1: 表情包里面的gif/png图片 -->
<view v-if="item.type == 'iconMenus' &&
item.dataType && item.dataType == 'image'">
<chat-item-image :item="item" :index="index"
@click="previewImage(item,index)"
imageClass="rounded"
:maxWidth="300" :maxHeight="300"></chat-item-image>
</view>
<!-- 情况2: 发图片 -->
<view v-else-if="item.type == 'image'">
<chat-item-image :item="item" :index="index"
@click="previewImage(item,index)"
imageClass="rounded"
:maxWidth="300" :maxHeight="400"></chat-item-image>
</view>
<!-- 情况3: 语音 -->
<view v-else-if="item.type == 'audio'">
<chat-item-audio :item="item" :index="index"
:isMe="isMe"></chat-item-audio>
</view>
<!-- 文字 -->
<text v-else
class="font" style="text-align: justify;">
{{item.data}}
</text>
</view>
<!-- 我 -->
<text v-if="isMe && needQipaoClass"
class="iconfont font-md chat-right-icon"></text>
<u--image v-if="isMe"
:src="item.avatar"
mode="widthFix"
width="80rpx" height="80rpx" radius="10rpx"></u--image>
</view>
<!-- 弹出菜单 -->
<chat-tooltip ref="chatTooltip" :mask="true"
:maskTransparent="true" :isBottom="false"
:tooltipWidth="tooltipWidth"
:tooltipHeight="60"
tooltipClass="bg-dark border-0 text-white">
<view class="flex flex-row flex-1">
<view class="flex-1 align-center justify-center"
hover-class="bg-hover-dark"
v-for="(item,index) in getmenuList" :key="index"
@click="clickType(item.type)">
<text class="text-white">{{item.name}}</text>
</view>
</view>
<!-- 箭头 -->
<text class="position-fixed iconfont text-dark"
style="font-size: 40rpx;"
:style="jiantouStyle"></text>
</chat-tooltip>
</view>
</template>
<script>
import parseTimeJs from '@/common/mixins/parseTime.js';
export default{
name:"chat-item",
mixins:[parseTimeJs],
props:{
item:Object,
index:Number,
//上一条时间
prevTime:[Number,String],
},
data(){
return {
menuEveHeight: 80, //每个菜单默认高度是60rpx
menuList: [{
name: "复制",
type: 'copy'
},
{
name: "撤回",
type: 'removeChatItem'
},
],
tooltipLeft:0, //弹出菜单组件left x
tooltipTop:0, //弹出菜单组件top y
// 组件内容超过这个宽度,菜单居中,
//否则菜单弹出位置由点击位置决定
rectmaxWidth:0,
longpressObj:null, // 存储长按信息内容
}
},
computed:{
// 我的判断, 假设我的id=2,后期由实际数据在更换
isMe(){
let user_id = 2;
return this.item.user_id === user_id;
},
// 显示聊天时间
chatShowTime(){
return parseTimeJs.getChatTime(this.item.chat_time,this.prevTime);
},
tooltipHeight() {
return this.getmenuList.length * this.menuEveHeight;
},
tooltipWidth(){
return this.getmenuList.length * 120;
},
jiantouStyle(){
let left = uni.upx2px(750-40) / 2;
let top = this.tooltipTop + uni.upx2px(40 + 5);
let jiantouCss = ``;
if(this.longpressObj && this.longpressObj.rect.width < this.rectmaxWidth){
top = this.longpressObj.y - 10;
left = this.longpressObj.x + 5;
jiantouCss = `transform:rotate(180deg);`;
}
return `left:${left}px;top:${top}px;${jiantouCss}`;
},
// 弹窗菜单处理
getmenuList(){
return this.menuList.filter(v=>{
if(v.name == '撤回' && !this.isMe){
return false
}
return true;
})
},
// 需要气泡样式
needQipaoClass(){
return this.item.type === 'text' ||
this.item.type === 'audio' ||
(this.item.type === 'iconMenus' && this.item.dataType === 'emoji');
},
// 聊天内容音频样式
contentStyle(){
if(this.item.type == 'audio'){
const duration = this.item.otherData.duration || 0;
// 最长时长60秒
const ratio = duration / 60 ;
// 最大宽度是500rpx 最小宽度120rpx
const width = 500 * ratio < 120 ? 120 : 500 * ratio;
return `width:${width}rpx;`;
}
},
},
methods:{
//预览图片
previewImage(item,index){
console.log('预览图片',item);
// 预览单个图片
// uni.previewImage({
// urls:[item.data]
// })
// 预览多张图片
this.$emit('previewImages',{
item,
index
});
},
clickType(e) {
console.log('点击菜单',e);
switch (e){
case 'copy':
break;
case 'removeChatItem':
this.item.isremove = true;
break;
}
this.$refs.chatTooltip.hide();
},
onLongpress(e,index,item){
console.log('组件里面的事件对象',e);
let x = 0,
y = 0;
// #ifdef H5 || MP
x = e.changedTouches[0].clientX;
y = e.changedTouches[0].clientY;
// #endif
// #ifdef APP
x = e.changedTouches[0].screenX;
y = e.changedTouches[0].screenY;
// #endif
/*
this.$emit('Longpress',{
x,
y,
index,
item
});
*/
// #ifdef H5 || MP
const query = uni.createSelectorQuery().in(this);
query.select(`.chatItem${index}`).boundingClientRect(rect=>{
if(rect){
console.log('内容部分距离各个方向',rect);
this.longpressfn({
x,
y,
index,
item,
rect:rect
});
}
}).exec();
// #endif
// #ifdef APP
const refName = 'chatItem' + index;
const ref = this.$refs[refName];
if (!ref) {
console.error('未找到元素引用: ' + refName);
return;
}
// 使用 Weex 的 dom 模块获取位置
const dom = weex.requireModule('dom');
dom.getComponentRect(ref, result => {
if (result && result.result) {
const rect = result.size;
console.log('组件距离各个方向距离:', rect);
this.longpressfn({
x,
y,
index,
item,
rect:rect
});
} else {
console.error('获取位置失败', result);
}
});
// #endif
},
longpressfn(e){
this.longpressObj = e;
console.log('长按得到longpressObj', e);
// 组件内容超过这个宽度,菜单居中,
//否则菜单弹出位置由点击位置决定
this.rectmaxWidth = uni.upx2px((750 - 60 - 80 - (35 + 10 - 5)) / 2);
console.log('内容最大宽度',this.rectmaxWidth);
if(e.rect.width >= this.rectmaxWidth){
this.tooltipLeft = (uni.upx2px(750 - this.tooltipWidth)) / 2;
this.tooltipTop = e.y - uni.upx2px(60 + 40 - 15);
}else{
this.tooltipLeft = e.x;
this.tooltipTop = e.y;
}
this.$refs.chatTooltip.show(this.tooltipLeft, this.tooltipTop);
}
}
}
</script>
<style scoped>
/* #ifdef H5 */
@import '/common/css/common.nvue.vue.css';
/* #endif */
.chat-left-icon{
left:25rpx;top: 20rpx;z-index: 100;color:#ffffff;
}
.chat-right-icon{
right:25rpx;top: 20rpx;z-index: 100;color:#95ec69;
}
.chat-left-content-bg{
background-color: #ffffff;
}
.chat-right-content-bg{
background-color: #95ec69;
}
</style>
# 3. 组件 /pages/chat-item-audio/chat-item-audio.vue
<template>
<view class="flex align-center" @click="toggleAudio(item,index)"
:class="[isMe?'justify-end':'justify-start']">
<view v-if="!isMe">
<image :src="audioPlayStatus ? audioIconList.notme.play:audioIconList.notme.stop"
style="width: 36rpx;height: 36rpx;"></image>
</view>
<text class="font"
:class="[isMe?'mr-1':'ml-1']">{{item.otherData.duration + "'"}}</text>
<!-- 我 音频 素材图片 -->
<view v-if="isMe">
<image :src="audioPlayStatus ? audioIconList.me.play :audioIconList.me.stop"
style="width: 36rpx;height: 36rpx;"></image>
</view>
</view>
</template>
<script>
import {mapState,mapGetters,mapMutations,mapActions} from 'vuex';
export default{
name:"chat-item-audio",
props:{
item:Object,
index:Number,
isMe:Boolean,
},
data(){
return {
// 音频上下文
innerAudioContext:null,
// 播放语音动画素材
audioIconList:{
me:{
stop:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-me-icon.png',
play:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-me-icon.gif'
},
notme:{
stop:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-friend-icon.png',
play:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/audio/audio-icon/audio-friend-icon.gif'
},
},
// 播放状态
audioPlayStatus:false,
// 记录当前播放位置
currentPosition:0,
}
},
computed:{
...mapState({
// testVuexValue:state=>state.testVuex,
testVuexValue:state=>state.Audio.testVuex,
}),
},
mounted() {
if(this.item.type == 'audio'){
// this.$onGlobalEvent(res=>{
// console.log('监听$emitEventName方法的消息',res);
// });
//this.$onGlobalEvent(this.onplayAudio);
uni.$on('onplayAudio',res=>{
// console.log('通过uni.$on接受的结果',res);
this.onplayAudio(res);
})
}
console.log('vuex中的state的值',this.testVuexValue);
},
destroyed() {
// this.$offEventName(this.onplayAudio);
uni.$off('onplayAudio');
//销毁音频
if(this.innerAudioContext){
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
},
methods:{
// 引入actions里面的方法
...mapActions(['$onGlobalEvent','$emitEventName','$offEventName']),
/*
// 全局监听播放语音的处理
onplayAudio(res){
// console.log('监听$emitEventName方法的消息',res);
if(this.innerAudioContext){
//停止非点击的音频
if(res != this.index){
this.innerAudioContext.stop();
}
}
},
//播放语音
playAudio(item,index){
// this.$emitEventName('执行一个全局事件传一个要播放的音频索引:' + index);
// return;
// 通知其它非点击音频停止播放
//this.$emitEventName(index);
uni.$emit('onplayAudio',{index:index});
if(!this.innerAudioContext){
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.src = item.data;
this.innerAudioContext.play();
// 监听语音播放
this.innerAudioContext.onPlay(()=>{
this.audioPlayStatus = true;
});
// 监听语音暂停
this.innerAudioContext.onPause(()=>{
this.audioPlayStatus = false;
});
// 监听语音停止
this.innerAudioContext.onStop(()=>{
this.audioPlayStatus = false;
});
// 监听语音错误
this.innerAudioContext.onError(()=>{
this.audioPlayStatus = false;
});
// 监听语音自然播放结束事件
this.innerAudioContext.onEnded(()=>{
this.audioPlayStatus = false;
});
}else{
this.innerAudioContext.stop();
this.innerAudioContext.play();
}
},
*/
// 全局监听播放语音的处理
onplayAudio(res){
if(this.innerAudioContext && res != this.index){
this.innerAudioContext.stop();
}
},
// 切换音频播放状态
toggleAudio(item,index){
// 如果当前正在播放,则停止
if(this.audioPlayStatus){
this.innerAudioContext.stop();
return;
}
// 通知其它非点击音频停止播放
uni.$emit('onplayAudio',{index:index});
if(!this.innerAudioContext){
this.createAudioContext(item);
} else {
// 恢复上次播放位置
this.innerAudioContext.seek(this.currentPosition);
this.innerAudioContext.play();
}
},
// 创建音频上下文
createAudioContext(item){
this.innerAudioContext = uni.createInnerAudioContext();
this.innerAudioContext.src = item.data;
// 监听语音播放
this.innerAudioContext.onPlay(()=>{
this.audioPlayStatus = true;
});
// 监听语音暂停
this.innerAudioContext.onPause(()=>{
this.audioPlayStatus = false;
});
// 监听语音停止
this.innerAudioContext.onStop(()=>{
this.audioPlayStatus = false;
// 记录停止位置
this.innerAudioContext.onTimeUpdate(() => {
this.currentPosition = this.innerAudioContext.currentTime;
});
});
// 监听语音错误
this.innerAudioContext.onError(()=>{
this.audioPlayStatus = false;
});
// 监听语音自然播放结束事件
this.innerAudioContext.onEnded(()=>{
this.audioPlayStatus = false;
this.currentPosition = 0; // 播放完成后重置位置
});
this.innerAudioContext.play();
},
},
}
</script>
<style>
/* #ifdef H5 */
@import '/common/css/common.nvue.vue.css';
/* #endif */
</style>
# 4. 音频模块 /store/modules/audio.js
export default{
// 对应的mapState,在computed中引用导入
// 类似于data,把全局或者公共部分放在这里
state:{
testVuex:'即时通讯',
// 存储全局事件名称的数组
eventNames:[],
recorderManager:null, // 全局唯一的录音管理器 recorderManager
recorderDurationTimer:null, //定时器
recorderDuration:0, // 针对app没有录音时长的处理
sendMessage:null, // 发送语音
},
// 同步的方法,在methods引入
mutations:{
// 初始化全局唯一的录音管理器 recorderManager
initRecorderManager(state){
//获取全局唯一的录音管理器 recorderManager
state.recorderManager = uni.getRecorderManager();
// 监听录音结束 获取录音,
state.recorderManager.onStop(res=>{
if(state.recorderDurationTimer){
clearInterval(state.recorderDurationTimer);
state.recorderDurationTimer = null;
}
console.log('录音地址',res);
// if(!this.isRecorderCancel){
// res.duration = this.recorderDuration * 1000;
// this.sendMessage('audio',res);
// }
if(typeof state.sendMessage == 'function'){
state.sendMessage(res);
}
});
// 监听录音开始
state.recorderManager.onStart(()=>{
state.recorderDuration = 0;
state.recorderDurationTimer = setInterval(()=>{
state.recorderDuration ++;
},1000);
});
},
// 全局注册一个发送语音的事件
regSendMessage(state,eventName){
state.sendMessage = eventName;
},
// 写一个注册全局事件的函数(方法)
regGlobalEvent(state,eventName){
// 如果用户调用了mutations里面的方法
// 把用户调用的这个方法名称存进state中的eventNames数组
console.log('注册全局事件:' + eventName);
state.eventNames.push(eventName);
},
// 触发全局事件函数(方法)每个eventName
executeEventName(state,params){
state.eventNames.forEach(eventName=>{
eventName(params);
});
},
// 注意全局事件不用要及时注销移除
removeEventName(state,eventName){
// 先找到对应事件(方法)
let index = state.eventNames.
findIndex(ename => ename === eventName);
// 如果找到了事件(方法),则删除这个事件(方法)
if(index > -1){
console.log('注销移除了事件:' + eventName);
state.eventNames.splice(index,1);
}
},
},
// 异步的方法,在methods引入
actions:{
// 处理regGlobalEvent这些函数(方法)
$onGlobalEvent({commit},eventName){
commit('regGlobalEvent',eventName);
},
// 执行executeEventName函数(方法)
$emitEventName({commit},params){
commit('executeEventName',params);
},
// 执行removeEventName函数(方法)
$offEventName({commit},eventName){
commit('removeEventName',eventName);
},
}
}
# 5. app入口文件 App.vue
<style lang="scss">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "@/uni_modules/uview-ui/index.scss";
</style>
<script>
export default {
onLaunch: function() {
console.log('App Launch')
// #ifdef APP-PLUS-NVUE
//加载图标库
const domModule = weex.requireModule('dom')
domModule.addRule('fontFace', {
'fontFamily': 'iconfont',
'src': "url('https://at.alicdn.com/t/font_1605591_gom9ls29eyh.ttf')",
// 'src':"url('/static/my.ttf')",
})
// #endif
// 监听应用状态变化
// #ifdef APP-PLUS
let appHideTime = 0;
plus.globalEvent.addEventListener('pause', () => {
console.log('应用进入后台');
appHideTime = Date.now();
uni.$emit('app_hide');
});
plus.globalEvent.addEventListener('resume', () => {
console.log('应用回到前台');
const hideDuration = Date.now() - appHideTime;
console.log(`应用在后台停留时间: ${hideDuration}ms`);
uni.$emit('app_show', { hideDuration });
});
// #endif
// #ifdef APP-PLUS || MP
// 初始化全局唯一的录音管理器 recorderManager
this.$store.commit('initRecorderManager');
// #endif
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
}
}
</script>
<style>
/*每个页面公共css */
@import '/common/css/common.nvue.vue.css';
@import '/common/css/theme.css';
/* #ifndef APP-PLUS-NVUE */
@import '/common/css/icon.css';
/* #endif */
</style>
# 6. main.js
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import Store from './store'; // 引入vuex
Vue.prototype.$store = Store; // 挂载到vue的原型中进行共享
// main.js
import uView from '@/uni_modules/uview-ui'
Vue.use(uView)
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
Store, // vuex
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif
# 三、加号扩展菜单功能
内容过多,在新页面打开,具体查看: