# 一、 发语音界面开发

# 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">&#xe644;</text>
				</chat-navbar-icon-button>
				<!-- 语音图标 -->
				<chat-navbar-icon-button v-else
				@click="changeAudioOrText('audio')">
					<text class="iconfont font-lg">&#xe643;</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">&#xe644;</text>
				</chat-navbar-icon-button>
				<!-- 语音图标 -->
				<chat-navbar-icon-button v-else
				@click="changeAudioOrText('audio')">
					<text class="iconfont font-lg">&#xe643;</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. 发语音功能实现

  1. uni-app中的api接口:https://uniapp.dcloud.net.cn/api/#媒体 (opens new window)
  2. 录音接口: 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 进行处理
很显然:

  1. 定义全局唯一的录音管理器: 放在了当前/pages/chat/chat.nvue页面,如果其他页面也需要录音功能,那么这个定义就不唯一,更不是全局唯一
  2. 在页面上,我们在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">&#xe626;</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">&#xe644;</text>
				</chat-navbar-icon-button>
				<!-- 语音图标 -->
				<chat-navbar-icon-button v-else
				@click="changeAudioOrText('audio')">
					<text class="iconfont font-lg">&#xe643;</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">&#xe642;</text>
				</chat-navbar-icon-button>
				<chat-navbar-icon-button v-if="!messageValue"
				@click="openPlus">
					<text class="iconfont font-lg">&#xe637;</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">&#xe609;</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">&#xe640;</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">&#xe649;</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

# 三、加号扩展菜单功能

内容过多,在新页面打开,具体查看:

  1. 聊天页输入区域:加号扩展菜单功能(发图片)
  2. 聊天页输入区域:加号扩展菜单功能:语音功能(语音播放)
  3. 聊天页输入区域:加号扩展菜单功能:语音功能(发语音)
  4. 聊天页输入区域:加号扩展菜单功能:播放视频及发视频
更新时间: 2025年7月7日星期一下午3点44分