# 一、消息页视频播放封面开发

素材准备,给大家提供一下视频素材和封面,大家也可以用自己的素材测试

  1. 视频素材: https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemo.mp4
  2. 视频封面素材: https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg

# 1. 页面补充数据

在页面 /pages/chat/chat.nvue

...
{
    avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
    nickname: '小二哥',
    chat_time: 1750148999,
    data: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg',
    otherData:{
        duration:'1:18', // 时长
        poster:"https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg",
        videoData:"https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemo.mp4",
    },
    user_id: 2,
    type:'video', //image,video
    isremove:false,
},

# 2. 组件新增视频播放情况

在组件 /components/chat-item/chat-item.vue

<!-- 情况4: 视频  -->
<view v-else-if="item.type == 'video'">
    <chat-item-video-poster :item="item" :index="index">
    </chat-item-video-poster>
</view>

# 3. 新增视频封面组件

新增 /components/chat-item-video-poster/chat-item-video-poster.vue

<template>
	<view class="position-relative" @click="openVideoShow">
		<!-- 视频封面 -->
		<chat-item-image :item="item" :index="index"
		imageClass="rounded"
		:maxWidth="300" :maxHeight="400"></chat-item-image>
		<!-- 蒙版 -->
		<view class="position-absolute left-0 right-0 top-0 bottom-0 rounded"
		style="z-index: 98;background-color: rgba(0, 0, 0, 0.4);"></view>
		<!-- 时长 -->
		<view class="position-absolute left-0 top-0 right-0 bottom-0 flex flex-row align-end justify-end mr-1 mb-1" style="z-index: 99;">
			<text class="font-sm text-white">{{item.otherData.duration}}</text>
		</view>
		<!-- 播放图标 -->
		<view class="position-absolute left-0 top-0 right-0 bottom-0 flex flex-row align-center justify-center" style="z-index: 100;">
			<text class="iconfont text-white"
			:style="videoPlayIconStyle">&#xe710;</text>
		</view>
	</view>
</template>

<script>
	export default{
		name:"chat-item-video-poster",
		props:{
			item:Object,
			index:Number,
		},
		data(){
			return {
				// 播放图片大小默认70rpx
				videoPlayIcon:70,
			}
		},
		methods:{
			openVideoShow(){
				// 打开视频
				const videoUrl = this.item.otherData.videoData;
				const title = encodeURIComponent(this.item.otherData.title || '视频');
				const poster = encodeURIComponent(this.item.otherData.poster || '');
				
				uni.navigateTo({
					url:`/pages/videoShow/videoShow?videoUrl=${videoUrl}&title=${title}&poster=${poster}`,
					animationType:'zoom-fade-out',
				})
			},
		},
		computed:{
			videoPlayIconStyle(){
				return `font-size: ${this.videoPlayIcon}rpx;`;
			}
		}
	}
</script>

<style>
	/* #ifdef H5 */
	@import '/common/css/common.nvue.vue.css';
	/* #endif */
</style>

# 二、视频播放页开发

# 1. 新建页面及配置

  1. 新建页面 /pages/videoShow/videoShow.nvue
  2. 配置页面 pages.json
{
	"path" : "pages/videoShow/videoShow",
	"style" : {
		"navigationBarTitleText":"视频播放",
		"app-plus":{
			"scrollIndicator":"none",
			"titleNView":false
		},
		"navigationStyle":"custom"
	}
}

# 2. 进入页面传递参数

从组件 /components/chat-item-video-poster/chat-item-video-poster.vue 进入

    methods:{
		openVideoShow(){
			// 打开视频
			const videoUrl = this.item.otherData.videoData;
			const title = encodeURIComponent(this.item.otherData.title || '视频');
			const poster = encodeURIComponent(this.item.otherData.poster || '');
			
			uni.navigateTo({
				url:`/pages/videoShow/videoShow?videoUrl=${videoUrl}&title=${title}&poster=${poster}`,
				animationType:'zoom-fade-out',
			})
		},
	},

# 3. 页面视频播放

在页面 /pages/videoShow/videoShow.nvue 用到uni-app提供的<video>组件:https://uniapp.dcloud.net.cn/component/video.html (opens new window)

<template>
	<view class="position-relative bg-dark">
		<video :src="video_url"  autoplay loop  :controls="true" 
		page-gesture show-progress
		enable-play-gesture  :http-cache="true" 
		:show-fullscreen-btn="false"
		x5-video-player-type='h5-page'
		:poster="posterUrl"
		style="width: 750rpx;" :style="'height:'+windowHeight+'px;'" 
		:direction="0" id="myVideo" :title="titleText"
		@ended="videoEnd" 
		@fullscreenchange="fullscreenchange"
		@fullscreenclick="fullscreenclick" 
		@controlstoggle="controlstoggle"
		@loadedmetadata="loadedmetadata">
		    <!-- #ifndef MP -->
		    <cover-view class="text-white position-absolute font-lg" 
			@click="navigateBack"
			style="left: 30rpx;bottom: 140rpx;">
			    <view style="background-color: rgba(255,255,220,0.2);
				height: 60rpx;border-radius: 35rpx;" 
				class="py-1 px-2 justify-center align-center flex flex-row">
					<u-icon name="close" size="18" color="#f9f9f9"></u-icon>
					<text class="font text-white ml-1">关闭视频</text>
				</view>
			</cover-view>
			<!-- #endif -->
		</video>
		<!-- #ifdef MP -->
		<view class="position-absolute" @click="navigateBack"
		style="left: 30rpx;bottom: 140rpx;">
		    <view style="background-color: rgba(255,255,220,0.2);
			height: 60rpx;border-radius: 35rpx;"
		    class="py-1 px-2 justify-center align-center flex flex-row">
		        <u-icon name="close" size="18" color="#f9f9f9"></u-icon>
		    	<text class="font text-white ml-1">关闭视频</text>
		    </view>
		</view>
		<!-- #endif -->
	</view>
</template>

<script>
	import toolJs from '@/common/mixins/tool.js'; 
	export default {
		mixins:[toolJs],
		data() {
			return {
				//避免props冲突
				//uni-app会自动将URL参数注入为页面props,使用同名变量会导致冲突
				//通过重命名变量,明确区分URL参数和本地数据
				//遵循Vue数据流规范:
				//不直接修改props,而是使用本地变量存储需要修改的数据
				//保持单向数据流,避免不可预测的行为
				windowHeight:500,
				// 使用本地变量存储URL参数
				//将 videoUrl 改为 video_url
				//将 title 改为 titleText
				//将 poster 改为 posterUrl
				video_url: '',      // 视频URL
				posterUrl: '',     // 封面URL
				titleText: '',     // 标题文本
			}
		},
		onReady(){
			//动态获取高度
			let res=uni.getSystemInfoSync()
			this.windowHeight=res.windowHeight;
		},
		onLoad(e) {
			//在 onLoad 中将URL参数赋值给本地变量,而不是直接修改可能作为prop注入的变量
			console.log('到了视频界面', e);
			if(!e.videoUrl || e.videoUrl === ''){
				this.navigateBack();
				return uni.showToast({
					title:'非法视频',
					icon:'none'
				})
			}
			// 将URL参数赋值给本地变量
			this.video_url = e.videoUrl;
			
			if(e.title){
				this.titleText = decodeURIComponent(e.title);
				uni.setNavigationBarTitle({
					title:this.titleText,
				})
			}
			
			if(e.poster){
				this.posterUrl = decodeURIComponent(e.poster)
				console.log('封面地址',this.posterUrl);
			}
		},
		methods: {
			videoEnd(){
				console.log('视频播放完了');
			},
			fullscreenchange(e){
				console.log('当视频进入和退出全屏时触发',e);
			},
			fullscreenclick(e){
				console.log('视频播放全屏播放时点击事件',e);
			},
			controlstoggle(e){
				console.log('切换 controls 显示隐藏时触发',e.detail);
			},
			loadedmetadata(e){
				console.log('视频元数据加载完成时触发',e.detail);
			}
		}
	}
</script>

<style>
	/* #ifdef H5 */
	@import '/common/css/common.nvue.vue.css';
	/* #endif */
</style>

# 三、相册选择视频发送

# 1. 聊天页加号菜单功能数据调整和代码分离

我们的chat.nvue页面代码太多了,可以采用mixins方法将页面代码分离一部分处理

  1. 新建js文件 /pages/chat/plusIconAction.js
import UniPermission from '@/common/mixins/uni_permission.js';

// H5端获取视频封面:videoPath 是视频地址
function getH5VideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        // H5端直接使用视频路径创建video元素
        const video = document.createElement('video');
        video.src = videoPath;
        video.crossOrigin = 'anonymous';
        video.addEventListener('loadeddata', function() {
            video.currentTime = 0.1; // 设置到0.1秒获取封面
            
            video.addEventListener('seeked', function() {
                const canvas = document.createElement('canvas');
                canvas.width = video.videoWidth;
                canvas.height = video.videoHeight;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
                
                // 获取封面图
                const thumbnail = canvas.toDataURL('image/jpeg');
                resolve(thumbnail);
            });
        });
    });
}



export default{
	data(){
		return {
			videoPlaceHolderPoster:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/video_placeholder.jpg',
			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:"\ue62c", iconType:"custom", eventType:"cameraPhoto" },
			    { name:"拍视频", icon:"\ue682", iconType:"custom", eventType:"cameraVideo" },
				{ name:"相册视频", icon:"\ue66d", iconType:"custom", eventType:"video" },
				{ name:"位置", icon:"map", iconType:"uview", eventType:"map" },
			    { name:"我的名片", icon:"\ue69d", iconType:"custom", eventType:"mingpian" },
				// { 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,
				},
				{
					avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
					nickname: '小二哥',
					chat_time: 1750148999,
					data: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg',
					otherData:{
						duration:'1:18', // 视频时长
						poster:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg',
						videoData:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemo.mp4',
					},
					user_id: 2,
					type:'video', //image,video
					isremove:false,
				},
			],
										
		}
	},
	methods:{
		//点击加号扩展菜单的某一项
		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.handlePermission({
							permission:{
								name:'photo',
								title:'本功能需要您打开相册',
								desc:'需要访问您的相册来选择图片',
								grantedText:'用户已授权开启相册,可以选择照片了',
								nograntedText:'您没有授权开启相册,无法发图片',
							},
							methodsName:'chooseImage',
						});
						break;
					case 'video':
					    await this.handlePermission({
					    	permission:{
					    		name:'photo',
					    		title:'本功能需要您打开相册',
					    		desc:'需要访问您的相册来选择视频',
					    		grantedText:'用户已授权开启相册,可以选择视频了',
					    		nograntedText:'您没有授权开启相册,无法发视频',
					    	},
					    	methodsName:'chooseVideo',
					    });
						break;
					case 'map':
						break;
					case 'camera':
						break;
					case 'mingpian':
						break;
					
				}
			}
		},
		// 发相册图片|发相册视频 权限申请
		async handlePermission(options){
			try{
			   const permission = new UniPermission();
			   const granted =  await permission.requestPermission(options.permission.name,
			   options.permission.desc,options.permission.title);
			   if(granted){
				   console.log(options.permission.grantedText);
				   this[options.methodsName]();
			   }else{
				   uni.showToast({
					  title: options.permission.nograntedText,
					  icon:'none',
					  duration:3000
				   });
			   }
			}catch(error){
				console.error('权限申请异常:' + error);
				uni.showToast({
					title:'权限申请失败:' + error.message,
					icon:'none',
					duration:3000
				});
			}
		},
		//选择相册视频发送
		async chooseVideo(){
			uni.chooseVideo({
				sourceType:['album'],
				// extension:['mp4'],
				compressed:true,
				maxDuration:60,
				camera:'back',
				success: async (res) => {
					console.log('选择相册视频res',res);
					if(res.tempFilePath){
						// 发送视频到服务器或者第三方云存储(如:阿里云oss)
						// 成功发送之后 由服务器或者第三方云存储返回信息如
						// 视频地址、视频封面等
						// 得到返回的信息进行页面渲染
						let poster = '';
						let durationFormatted = '';
						// #ifdef H5
						try{
							// h5封面
							poster = await getH5VideoThumbnail(res.tempFilePath);
							durationFormatted = this.formatVideoDuration(res.duration);
						}catch(e){
							console.log('H5获取封面失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						// #ifdef MP
						try{
							// 小程序封面
							poster = res.thumbTempFilePath;
							durationFormatted = this.formatVideoDuration(res.duration);
						}catch(e){
							console.log('小程序获取封面失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						// #ifdef APP
						try{
							// APP封面
							//引入插件
							// if(uni.getSystemInfoSync().platform === "android"){
							// 	// 安卓手机获取封面
							// 	const plug=uni.requireNativePlugin("Html5app-VideoCover");
							// 	plug.setVideoPath({
							// 		'url':res.tempFilePath,
							// 		'time':1,
							// 	},ret=>{
							// 		console.log('安卓手机获取封面的结果',ret);
							// 	});
							// }
							poster = this.videoPlaceHolderPoster;
							durationFormatted = this.formatVideoDuration(res.duration);
						}catch(e){
							console.log('APP封面获取失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						
						// 发视频
						this.sendMessage('video',{
							...res,
							videoPath:res.tempFilePath,
							poster:poster,
							duration:durationFormatted
						});
						
					}
				},
				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
					});
				}
			});
		},
		//选择相册照片发送
		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',
			});
		},
		//发送消息
		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;
				case 'video':
				    console.log('video的数据',option);
					msg.data = option.poster;
					msg.otherData = {
						duration:option.duration, // 视频时长
						poster:option.poster,
						videoData:option.videoPath,
					};
				    break;
			}
			this.chatDataList.push(msg);
			// 清空发送的内容然后还要滚动到底部
			if(msgType == 'text') this.messageValue = '';
			this.chatContentToBottom();
		},
		//格式化视频时长
		formatVideoDuration(duration) {
			// 确保duration是数字类型
			const seconds = typeof duration === 'number' ? duration : parseFloat(duration) || 0;
			const minutes = Math.floor(seconds / 60);
			const remainingSeconds = Math.floor(seconds % 60);
			return `${minutes}:${remainingSeconds < 10 ? '0' + remainingSeconds : remainingSeconds}`;
		},
	},
}
  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 plusIconActionJs from '@/pages/chat/plusIconAction.js';
	import UniPermission from '@/common/mixins/uni_permission.js';
	import {mapState,mapGetters,mapMutations,mapActions} from 'vuex';
	export default {
		mixins:[toolJs,plusIconActionJs],
		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
			}
		},
		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);
							}
						}
					}
				});
			},
			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
			},
			
			//点击了三个点图标
			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. 获取视频封面图

一、正常方式是:视频发送到服务器或者第三方云存储(如阿里云OSS)后,由服务器或者第三方云存储生成封面图、视频地址等数据,并返回给客户端进行显示
二、特殊需求:若希望在本地生成视频封面,可以参考以下方案:

# ① 针对H5端(比较简单,采用video``canvas)即可

// H5端获取视频封面:videoPath 是视频地址
function getH5VideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        // H5端直接使用视频路径创建video元素
        const video = document.createElement('video');
        video.src = videoPath;
        video.crossOrigin = 'anonymous';
        video.addEventListener('loadeddata', function() {
            video.currentTime = 0.1; // 设置到0.1秒获取封面
            
            video.addEventListener('seeked', function() {
                const canvas = document.createElement('canvas');
                canvas.width = video.videoWidth;
                canvas.height = video.videoHeight;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
                
                // 获取封面图
                const thumbnail = canvas.toDataURL('image/jpeg');
                resolve(thumbnail);
            });
        });
    });
}

# ② 小程序端(在微信开发者工具上选择视频后,可直接返回视频封面,但是真机调试无法返回视频封面),有以下解决方案:

方案1:在小程序真机环境中,我们可以使用wx.getVideoInfo API来获取

//选择视频发送
async chooseVideo() {
    uni.chooseVideo({
        sourceType: ['album'],
        compressed: true,
        maxDuration: 60,
        camera: 'back',
        success: async (res) => {
            if (res.tempFilePath) {
                let poster = '';
                let durationFormatted = '';
                
                // #ifdef MP
                // 小程序端处理
                if (res.thumbTempFilePath) {
                    // 开发者工具有封面
                    poster = res.thumbTempFilePath;
                } else {
                    // 真机环境需要额外获取
                    try {
                        const videoInfo = await new Promise((resolve, reject) => {
                            wx.getVideoInfo({
                                src: res.tempFilePath,
                                success: resolve,
                                fail: reject
                            });
                        });
                        poster = videoInfo.thumbTempFilePath;
                    } catch (e) {
                        console.error('小程序获取视频信息失败:', e);
                        poster = this.videoPlaceHolderPoster;
                    }
                }
                durationFormatted = this.formatVideoDuration(res.duration);
                // #endif
                
                // ... 其他平台代码保持不变
                
                // 发送视频消息
                this.sendMessage('video', {
                    ...res,
                    videoPath: res.tempFilePath,
                    poster: poster,
                    duration: durationFormatted
                });
            }
        },
        fail: (err) => {
            // 错误处理保持不变
        }
    });
}

方案2:使用wx.createVideoContextcanvas组合方案(页面组件)

① 小程序端封面获取方法

// 小程序端封面获取方法
async function getMpVideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        // #ifdef MP
        try {
            // 创建隐藏video组件
            const videoId = `video-${Date.now()}`;
            const video = wx.createVideoContext(videoId, this);
            
            // 创建canvas
            const canvasId = `canvas-${Date.now()}`;
            const ctx = wx.createCanvasContext(canvasId);
            
            // 监听视频首帧加载
            video.seek(0);
            setTimeout(() => {
                video.snapshot({
                    success: (res) => {
                        resolve(res.tempImagePath);
                    },
                    fail: (err) => {
                        console.error('视频快照失败:', err);
                        resolve('');
                    }
                });
            }, 500);
            
            // 临时创建video和canvas元素
            this.$set(this, '_tempVideoId', videoId);
            this.$set(this, '_tempCanvasId', canvasId);
        } catch (e) {
            console.error('小程序封面生成失败:', e);
            resolve('');
        }
        // #endif
    });
}

② 修改chooseVideo方法整合

async chooseVideo() {
	// 选择相册视频发送
    uni.chooseVideo({
        sourceType: ['album'],
        compressed: true,
        maxDuration: 60,
        camera: 'back',
        success: async (res) => {
            if (res.tempFilePath) {
                let poster = '';
                let durationFormatted = '';
                
                // 平台特定处理
                // #ifdef MP
                poster = res.thumbTempFilePath || await getMpVideoThumbnail.call(this, res.tempFilePath);
                durationFormatted = this.formatVideoDuration(res.duration);
                // #endif
                
                // #ifdef H5
                ...
                // #endif
                
                // #ifdef APP
                ...
                // #endif
                
                // 发送视频消息
                this.sendMessage('video', {
                    ...res,
                    videoPath: res.tempFilePath,
                    poster: poster,
                    duration: durationFormatted
                });
            }
        },
        fail: (err) => {
            // 错误处理保持不变
        }
    });
}

③ 在chat.nvue页面中添加隐藏元素支持

<!-- 在template底部添加 -->
<template>
  <!-- ...其他内容... -->
  
  <!-- 隐藏的小程序video和canvas -->
  <!-- #ifdef MP -->
  <video 
    :id="_tempVideoId" 
    :src="tempVideoPath" 
    style="position:absolute;width:1px;height:1px;opacity:0;"
    v-if="tempVideoPath"
  ></video>
  <canvas 
    :id="_tempCanvasId" 
    style="position:absolute;width:1px;height:1px;"
  ></canvas>
  <!-- #endif -->
</template>

//在data中添加临时变量
data() {
  return {
    // ...其他数据...
    _tempVideoId: '',
    _tempCanvasId: '',
    tempVideoPath: ''
  }
}

方案3:整合方案1和方案2

① 小程序封面获取方法 在 plusIconAction.js

// 小程序端封面获取方法
async function getMpVideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        // #ifdef MP
        try {
            // 方案1:尝试使用 wx.getVideoInfo
            if (typeof wx.getVideoInfo === 'function') {
                wx.getVideoInfo({
                    src: videoPath,
                    success: (res) => {
                        if (res.thumbTempFilePath) {
                            resolve(res.thumbTempFilePath);
                        } else {
                            // 如果获取不到,使用默认封面
                            resolve(this.videoPlaceHolderPoster);
                        }
                    },
                    fail: (err) => {
                        console.error('wx.getVideoInfo失败:', err);
                        resolve(this.videoPlaceHolderPoster);
                    }
                });
            } 
            // 方案2:如果 wx.getVideoInfo 不可用,尝试使用 createVideoContext
            else if (typeof wx.createVideoContext === 'function') {
                // 创建隐藏video组件
                const videoId = `video-${Date.now()}`;
                this.$set(this, '_tempVideoId', videoId);
                this.tempVideoPath = videoPath;
                
                this.$nextTick(() => {
                    const video = wx.createVideoContext(videoId, this);
                    
                    // 检查 snapshot 方法是否存在
                    if (video.snapshot) {
                        // 监听视频首帧加载
                        video.seek(0);
                        setTimeout(() => {
                            video.snapshot({
                                success: (res) => {
                                    resolve(res.tempImagePath);
                                },
                                fail: (err) => {
                                    console.error('视频快照失败:', err);
                                    resolve(this.videoPlaceHolderPoster);
                                }
                            });
                        }, 500);
                    } else {
                        console.warn('snapshot方法不可用');
                        resolve(this.videoPlaceHolderPoster);
                    }
                });
            }
            // 方案3:都不支持,使用默认封面
            else {
                resolve(this.videoPlaceHolderPoster);
            }
        } catch (e) {
            console.error('小程序封面生成失败:', e);
            resolve(this.videoPlaceHolderPoster);
        }
        // #endif
    });
}

② 修改 chooseVideo 方法中的小程序部分

// #ifdef MP
// 小程序端处理
if (res.thumbTempFilePath) {
    // 如果 chooseVideo 返回了 thumbTempFilePath,直接使用
    poster = res.thumbTempFilePath;
} else {
    // 否则调用 getMpVideoThumbnail
    poster = await getMpVideoThumbnail.call(this, res.tempFilePath);
}
durationFormatted = this.formatVideoDuration(res.duration);
// #endif

③ 在 plusIconAction.js 的 data 中添加 tempVideoPath

data(){
    return {
        _tempVideoId: '',
        _tempCanvasId: '',
        tempVideoPath: '', // 添加这一行
        // ...其他数据...
    }
}

④ 在 chat.nvue 页面中修改隐藏的 video 组件

<!-- 隐藏的小程序video和canvas -->
<!-- #ifdef MP -->
<video 
    :id="_tempVideoId" 
    :src="tempVideoPath" 
    style="position:absolute;width:1px;height:1px;opacity:0;"
    v-if="tempVideoPath"
</video>
<canvas 
    :id="_tempCanvasId" 
    style="position:absolute;width:1px;height:1px;"
></canvas>
<!-- #endif -->

方案4:使用wx.createVideoContextcanvas组合方案(js内动态创建组件和绘制)

// 小程序端封面获取方法
async function getMpVideoThumbnail(videoPath) {
    return new Promise((resolve, reject) => {
        // #ifdef MP
        console.log('开始获取小程序视频封面');
        
        // 1. 创建临时video组件
        const videoId = 'tempVideo_' + Date.now();
        const videoContext = wx.createVideoContext(videoId, this);
        
        // 2. 动态添加video组件到页面
        const systemInfo = wx.getSystemInfoSync();
        this.$set(this, videoId, {
            src: videoPath,
            id: videoId,
            style: `position:absolute;left:-9999px;width:1px;height:1px;`,
            controls: false,
            autoplay: false,
            showCenterPlayBtn: false
        });
		
		console.log('动态添加video组件到页面');
        
        // 3. 等待组件渲染完成
        this.$nextTick(() => {
            // 4. 监听视频加载完成事件
            videoContext.onLoadedMetadata(() => {
                console.log('视频元数据加载完成');
                
                // 5. 跳转到0.1秒位置
                videoContext.seek(0.1);
                
                // 6. 监听跳转完成事件
                videoContext.onSeeked(() => {
                    console.log('视频跳转完成');
                    
                    // 7. 创建canvas绘制封面
                    const canvasId = 'tempCanvas_' + Date.now();
                    const canvasContext = wx.createCanvasContext(canvasId, this);
                    
                    // 8. 绘制视频帧到canvas
                    setTimeout(() => {
                        canvasContext.drawImage(videoPath, 0, 0, 100, 100);
                        canvasContext.draw(false, () => {
                            console.log('canvas绘制完成');
                            
                            // 9. 获取临时文件路径
                            wx.canvasToTempFilePath({
                                canvasId: canvasId,
                                success: (res) => {
                                    console.log('获取封面成功', res.tempFilePath);
                                    resolve(res.tempFilePath);
                                    
                                    // 10. 清理临时组件
                                    this.$set(this, videoId, null);
                                    this.$set(this, canvasId, null);
                                },
                                fail: reject
                            });
                        });
                    }, 300);
                });
            });
            
            // 11. 开始加载视频
            videoContext.play();
        });
        // #endif
        
        // 非小程序环境返回空字符串
        resolve('');
    });
}

// 2. 调用
// #ifdef MP
// 小程序端获取封面和时长
try {
	poster = res.thumbTempFilePath || 
	await getMpVideoThumbnail.call(this, res.tempFilePath);
	durationFormatted = this.formatVideoDuration(res.duration);
} catch(e) {
	console.error('小程序获取封面失败:', e);
	poster = this.videoPlaceHolderPoster;
	durationFormatted = '0:00';
}
// #endif

//3. 在页面的 data 中添加一个用于存储临时组件的对象
data() {
    return {
        // ...
        tempComponents: {} // 用于存储临时组件
    }
}

//4. 在页面中添加一个用于渲染临时组件的容器:
<view v-for="(comp, id) in tempComponents" :key="id" :style="comp.style">
    <video v-if="comp.id.includes('tempVideo')" 
           :id="comp.id" 
           :src="comp.src" 
           :controls="comp.controls"
           :autoplay="comp.autoplay"
           :show-center-play-btn="comp.showCenterPlayBtn"></video>
    <canvas v-if="comp.id.includes('tempCanvas')" 
            :id="comp.id" 
            canvas-id="comp.id" 
            style="width:100px;height:100px;"></canvas>
</view>

总结:以上方案可实现获取视频封面,但均存在兼容性问题,跟微信版本和uniapp版本有关,以上方法作为参考学习知识所用。

# ③ 针对app端

app端可尝试的方法

① 先尝试使用plus.media.getVideoInfo获取封面

// APP端获取视频封面
function getAppVideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        // 1. 先尝试使用plus.media.getVideoInfo获取封面
        plus.media.getVideoInfo({
            filePath: videoPath,
            success: (res) => {
                console.log('APP端使用plus.media.getVideoInfo获取视频信息成功:', res);
                if (res.cover && res.cover !== '') {
                    resolve(res.cover);
                } else {
                    console.log('APP端获取视频信息成功,但没有封面,尝试createThumbnail');
                    // 2. 如果没有封面,尝试使用createThumbnail创建缩略图
                    createVideoThumbnail(videoPath).then(cover => {
                        resolve(cover || '');
                    });
                }
            },
            fail: (err) => {
                console.error('APP端使用plus.media.getVideoInfo获取视频信息失败:', err);
                // 3. 如果getVideoInfo失败,直接尝试createThumbnail
                createVideoThumbnail(videoPath).then(cover => {
                    resolve(cover || '');
                });
            }
        });
    });
}

② 在①无效的情况下尝试createThumbnail创建缩略图

// APP端创建视频缩略图
function createVideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        plus.media.createThumbnail({
            src: videoPath,
            position: 1, // 1秒处
            width: 200,
            height: 200,
            format: 'jpg'
        }, (thumb) => {
            console.log('创建视频缩略图成功:', thumb);
            resolve(thumb);
        }, (err) => {
            console.error('创建视频缩略图失败:', err);
            resolve('');
        });
    });
}

经测试以上方法在安卓某些机型上存在兼容问题

③ 在APP端使用HTML5的<video><canvas>方案

// APP端封面获取方法
async function getAppVideoThumbnail(videoPath) {
    return new Promise(async (resolve) => {
        // #ifdef APP
        try {
            // 转换路径为可访问的URL
            const convertedPath = await new Promise((resolve) => {
                plus.io.resolveLocalFileSystemURL(videoPath, (entry) => {
                    resolve(entry.toLocalURL());
                }, (err) => {
                    console.error('路径转换失败:', err);
                    resolve(videoPath);
                });
            });

            console.log('视频转换路径为可访问的URL',convertedPath);
            
            // 创建临时webview获取封面
            const webview = plus.webview.create('', 'video-cover-webview', {
                background: 'transparent',
                height: '0px',
                width: '0px'
            });
            
            webview.addEventListener('loaded', async () => {
                try {
                    const cover = await webview.evalJS(`
                        new Promise(resolve => {
                            const video = document.createElement('video');
                            video.src = "${convertedPath}";
                            video.crossOrigin = 'anonymous';
                            
                            video.addEventListener('loadeddata', () => {
                                video.currentTime = 0.1;
                            });
                            
                            video.addEventListener('seeked', () => {
                                const canvas = document.createElement('canvas');
                                canvas.width = video.videoWidth;
                                canvas.height = video.videoHeight;
                                const ctx = canvas.getContext('2d');
                                ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
                                
                                // 获取封面图
                                const thumbnail = canvas.toDataURL('image/jpeg');
                                resolve(thumbnail);
                            });
                            
                            video.addEventListener('error', () => {
                                resolve('');
                            });
                        })
                    `);
                    
                    resolve(cover || '');
                } catch (e) {
                    console.error('APP封面生成失败:', e);
                    resolve('');
                } finally {
                    setTimeout(() => webview.close(), 100);
                }
            });
            
            webview.show('none');
        } catch (e) {
            console.error('APP封面处理异常:', e);
            resolve('');
        }
        // #endif
    });
}

经测试以上方法在安卓某些机型上存在兼容问题

④ 在app端使用5+Appplus.io.getVideoInfo https://www.html5plus.org/doc/zh_cn/io.html#plus.io.getVideoInfo (opens new window)但是在某些机型上没有返回封面图信息,然后在使用APP的Webview环境中执行H5封面生成方法 使用plus.webviewplus.bridge在原生和Webview间通信并包含本地文件路径转换逻辑,也就是③的方法

// app获取视频封面
async function getAppVideoThumbnail(videoPath) {
    return new Promise((resolve) => {
        // #ifdef APP
        try {
            // 1. 尝试使用plus.io.getVideoInfo
            if (typeof plus.io.getVideoInfo === 'function') {
                plus.io.getVideoInfo({
                    filePath: videoPath,
                    success: (res) => {
                        console.log('plus.io.getVideoInfo成功:', res);
                        if (res && res.coverPath) {
                            resolve(res.coverPath);
                        } else {
                            // 尝试备选方案
                            this.tryAlternativeAppCover(videoPath).then(resolve);
                        }
                    },
                    fail: (err) => {
                        console.error('plus.io.getVideoInfo失败:', err);
                        // 尝试备选方案
                        this.tryAlternativeAppCover(videoPath).then(resolve);
                    }
                });
            } else {
                console.warn('plus.io.getVideoInfo不可用');
                // 尝试备选方案
                this.tryAlternativeAppCover(videoPath).then(resolve);
            }
        } catch (e) {
            console.error('getAppVideoThumbnail异常:', e);
            this.tryAlternativeAppCover(videoPath).then(resolve);
        }
        // #else
        resolve('');
        // #endif
    });
}

// 备选方案:使用H5方法(在APP的Webview环境中)
async function tryAlternativeAppCover(videoPath) {
    return new Promise(async (resolve) => {
        try {
            // 转换路径为可访问的URL
            const convertedPath = await this.convertLocalFilePath(videoPath);
            
            // 在APP的Webview环境中执行H5方法
            const cover = await this.executeInWebviewContext(`
                new Promise(resolve => {
                    const video = document.createElement('video');
                    video.src = "${convertedPath}";
                    video.crossOrigin = 'anonymous';
                    
                    video.addEventListener('loadeddata', () => {
                        video.currentTime = 0.1;
                    });
                    
                    video.addEventListener('seeked', () => {
                        const canvas = document.createElement('canvas');
                        canvas.width = video.videoWidth;
                        canvas.height = video.videoHeight;
                        const ctx = canvas.getContext('2d');
                        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
                        
                        // 获取封面图
                        const thumbnail = canvas.toDataURL('image/jpeg');
                        resolve(thumbnail);
                    });
                    
                    video.addEventListener('error', () => {
                        resolve('');
                    });
                })
            `);
            
            resolve(cover || this.videoPlaceHolderPoster);
        } catch (e) {
            console.error('备选方案失败:', e);
            resolve(this.videoPlaceHolderPoster);
        }
    });
}

// 转换本地文件路径为可访问的URL
function convertLocalFilePath(filePath) {
    return new Promise((resolve) => {
        // #ifdef APP
        if (filePath.startsWith('file://')) {
            resolve(filePath);
            return;
        }
        
        plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
            resolve(entry.toLocalURL());
        }, (err) => {
            console.error('路径转换失败:', err);
            resolve(filePath);
        });
        // #else
        resolve(filePath);
        // #endif
    });
}

// 在Webview上下文中执行代码
function executeInWebviewContext(script) {
    return new Promise((resolve) => {
        // #ifdef APP
        const callbackId = `callback_${Date.now()}`;
        const webview = plus.webview.currentWebview();
        
        // 注册回调函数
        plus.bridge.callbackFromWebview(webview.id, callbackId, (result) => {
            resolve(result);
        });
        
        // 执行脚本
        webview.evalJS(`
            (${script})
            .then(result => {
                plus.bridge.callbackToWebview({
                    id: '${callbackId}',
                    data: result
                });
            })
            .catch(error => {
                console.error('Webview执行错误:', error);
                plus.bridge.callbackToWebview({
                    id: '${callbackId}',
                    data: ''
                });
            });
        `);
        // #else
        resolve('');
        // #endif
    });
}

经测试在安卓某些机型上存在兼容问题 以上方式虽然存在兼容问题,但是大家可以根据代码学习一下一些思路和解决方法

⑤ app端获取视频封面的终极方案:去uni-app插件市场选择一个插件引入即可,在插件市场搜索:视频封面 https://ext.dcloud.net.cn/search?q=视频封面 (opens new window) ,但是有些插件好用却存在一些问题

  1. 比如,好用的插件收费(不收费的插件也有好用的,但需要自己找一下)
  2. 如果插件下架,则影响到项目(虽然这种可能性很小)

    在线插件需要绑定包名,即需要申请安卓和ios证书先,因此为了教学,我直接给打开提供一个我自己编写的本地插件包,方便学习调试使用。

# 3. 发视频:视频占位和视频封面配合使用

有同学课后提出,说老师如果我不想用插件获取封面图,就想用视频占位,可不可以将视频封面和视频配合使用呢?

#/pages/chat/plusIconAction.js文件

...
export default{
	data(){
		return {
			...,
			chatDataList:[
				...,
				{
					avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/avatar-07.png',
					nickname: '小二哥',
					chat_time: 1750148999,
					data: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg',
					otherData:{
						duration:'1:18', // 视频时长
						poster:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemoPoster.jpg',
						videoData:'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemo.mp4',
						showPoster:true,
					},
					user_id: 2,
					type:'video', //image,video
					isremove:false,
				},
			],
										
		}
	},
	methods:{
		...
		//选择相册视频发送
		async chooseVideo(){
			uni.chooseVideo({
				sourceType:['album'],
				// extension:['mp4'],
				compressed:true,
				maxDuration:60,
				camera:'back',
				success: async (res) => {
					console.log('选择相册视频res',res);
					if(res.tempFilePath){
						// 发送视频到服务器或者第三方云存储(如:阿里云oss)
						// 成功发送之后 由服务器或者第三方云存储返回信息如
						// 视频地址、视频封面等
						// 得到返回的信息进行页面渲染
						let poster = '';
						let durationFormatted = '';
						let showPoster = false; // 是否展示封面
						// #ifdef H5
						try{
							// h5封面
							poster = await getH5VideoThumbnail(res.tempFilePath);
							durationFormatted = this.formatVideoDuration(res.duration);
							showPoster = true;
						}catch(e){
							console.log('H5获取封面失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						// #ifdef MP
						try{
							// 小程序封面
							poster = res.thumbTempFilePath;
							durationFormatted = this.formatVideoDuration(res.duration);
							showPoster = poster ? true : false;
						}catch(e){
							console.log('小程序获取封面失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						// #ifdef APP
						try{
							// APP封面
							//引入插件
							// if(uni.getSystemInfoSync().platform === "android"){
							// 	// 安卓手机获取封面
							// 	const plug=uni.requireNativePlugin("Html5app-VideoCover");
							// 	plug.setVideoPath({
							// 		'url':res.tempFilePath,
							// 		'time':1,
							// 	},ret=>{
							// 		console.log('安卓手机获取封面的结果',ret);
							// 	});
							// }
							poster = this.videoPlaceHolderPoster;
							durationFormatted = this.formatVideoDuration(res.duration);
						}catch(e){
							console.log('APP封面获取失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						
						// 发视频
						this.sendMessage('video',{
							...res,
							videoPath:res.tempFilePath,
							poster:poster,
							duration:durationFormatted,
							showPoster:showPoster,
						});
						
					}
				},
				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
					});
				}
			});
		},
		...
		//发送消息
		sendMessage(msgType, option = {}){
			...
			switch (msgType){
				...
				case 'video':
				    console.log('video的数据',option);
					msg.data = option.poster;
					msg.otherData = {
						duration:option.duration, // 视频时长
						poster:option.poster,
						videoData:option.videoPath,
						showPoster:option.showPoster, //是否显示封面
					};
				    break;
			}
			...
		},
		
	},
}

# ② 组件 /components/chat-item-video-poster/chat-item-video-poster.vue

<template>
	<view>
		<!-- 视频封面占位 -->
		<view v-if="item.otherData.showPoster"
		class="position-relative" @click="openVideoShow">
			<!-- 视频封面 -->
			<chat-item-image :item="item" :index="index"
			imageClass="rounded"
			:maxWidth="300" :maxHeight="400"></chat-item-image>
			<!-- 蒙版 -->
			<view class="position-absolute left-0 right-0 top-0 bottom-0 rounded"
			style="z-index: 98;background-color: rgba(0, 0, 0, 0.4);"></view>
			<!-- 视频时长 -->
			<view class="position-absolute left-0 right-0 top-0 bottom-0 rounded flex flex-row justify-end align-end mr-1 mb-1"   style="z-index: 99;">
				<text class="font-sm text-white">{{item.otherData.duration}}</text>
			</view>
			<!-- 播放按钮 -->
			<view class="position-absolute left-0 right-0 top-0 bottom-0 flex flex-row align-center justify-center"
			style="z-index: 100;">
				<text class="iconfont text-white"
				:style="videoPlayIconStyle">&#xe710;</text>
			</view>
		</view>
		<!-- 视频占位 -->
		<view v-else
		style="width: 200rpx;height: 350rpx;"
		class="position-relative">
			<video :src="item.otherData.videoData"
			style="width: 200rpx;height: 350rpx;"
			muted autoplay
			:initialTime="1" :controls="false"
			object-fit="cover">
			   <!-- #ifndef MP -->
			   <cover-view 
			   class="position-absolute left-0 right-0 top-0 bottom-0 flex flex-column align-center justify-center"
			   style="z-index: 98;background-color: rgba(0, 0, 0, 0.7);"
			   @click="openVideoShow">
                    <text class="font-sm text-white mb-2">视频已发送</text>
					<text class="font-sm text-white">50%</text>
			   </cover-view>
			   <!-- #endif -->
			</video>
			<!-- #ifdef MP -->
			<view class="position-absolute left-0 right-0 top-0 bottom-0 flex flex-column align-center justify-center"
			style="z-index: 98;background-color: rgba(0, 0, 0, 0.7);"
			@click="openVideoShow">
			    <text class="font-sm text-white mb-2">视频已发送</text>
			    <text class="font-sm text-white">50%</text>
			</view>
			<!-- #endif -->
		</view>
	</view>
</template>

<script>
	export default{
		name:"chat-item-video-poster",
		props: {
			item: Object,
			index: Number,
		},
		data(){
			return {
				// 播放视频图标大小默认70rpx
				videoPlayIcon:70,
			}
		},
		computed:{
			videoPlayIconStyle(){
				return `font-size: ${this.videoPlayIcon}rpx;`;
			}
		},
		methods:{
			openVideoShow(){
				// 打开视频
				const videoUrl = this.item.otherData.videoData;
				const title = encodeURIComponent(this.item.otherData.title || '视频');
				const poster = encodeURIComponent(this.item.otherData.poster || '');
				
				uni.navigateTo({
					url:`/pages/videoShow/videoShow?videoUrl=${videoUrl}&title=${title}&poster=${poster}`,
					animationType:'zoom-fade-out',
				})
			},
		}
	}
</script>

<style>
	/* #ifdef H5 */
	@import '/common/css/common.nvue.vue.css';
	/* #endif */
</style>

# 四、通过相机进行发图片、发视频

# 1. 方法合并处理

在文件 /pages/chat/plusIconAction.js

...

export default{
	data(){
		return {
			...		
		}
	},
	methods:{
		//点击加号扩展菜单的某一项
		async swiperItemClick(item, itemIndex) {
			...
			if (this.sendMessageMode === 'icon') {
				...
			} else {
				console.log('点击加号扩展菜单的某一项',item.eventType);
				switch (item.eventType){
					...,
					case 'cameraPhoto':
					    await this.handlePermission({
					    	permission:{
					    		name:'camera',
					    		title:'本功能需要您打开摄像头',
					    		desc:'需要访问您的摄像头来拍摄照片',
					    		grantedText:'用户已授权打开摄像头,可以拍摄照片了',
					    		nograntedText:'您没有授权打开摄像头,无法拍摄照片',
					    	},
					    	methodsName:'cameraPhoto',
					    });
						break;
					case 'cameraVideo':
					    await this.handlePermission({
					    	permission:{
					    		name:'camera',
					    		title:'本功能需要您打开摄像头',
					    		desc:'需要访问您的摄像头来拍摄视频',
					    		grantedText:'用户已授权打开摄像头,可以拍摄视频了',
					    		nograntedText:'您没有授权打开摄像头,无法拍摄视频',
					    	},
					    	methodsName:'cameraVideo',
					    });
						break;
					case 'map':
						break;
					case 'mingpian':
						break;
					
				}
			}
		},
		// 拍照片发送
		cameraPhoto(){
			this.sendPhotoAlbumOrCamera({
				count:1,
				sourceType:'camera',
			});
		},
		// 拍视频发送
		async cameraVideo(){
			await this.sendVideoAlbumOrCamera({
				sourceType:'camera',
			});
		},
		...,
		//选择相册视频发送
		async chooseVideo(){
			await this.sendVideoAlbumOrCamera({
				sourceType:'album',
			});
		},
		//选择相册照片发送
		chooseImage(){
			this.sendPhotoAlbumOrCamera({
				count:9,
				sourceType:'album',
			});
		},
		// 发照片:相册或者相机
		sendPhotoAlbumOrCamera(option){
			uni.chooseImage({
				count:option.count,
				//sizeType:['original','compressed'],
				sourceType:[option.sourceType],
				success: (res) => {
					console.log('选择照片res',res);
					if(res.tempFilePaths && res.tempFilePaths.length){
						// 发送到服务器或者第三方云存储
						// 页面效果渲染效果
						if(res.tempFilePaths.length == 1){
							//单张
							this.sendMessage('image',{path:res.tempFilePaths[0]});
						}else{
							// 多张
							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
					});
				}
			});
		},
		// 发视频:相册或者相机
		async sendVideoAlbumOrCamera(option){
			uni.chooseVideo({
				sourceType:[option.sourceType],
				// extension:['mp4'],
				compressed:true,
				maxDuration:60,
				camera:'back',
				success: async (res) => {
					console.log('选择相册视频res',res);
					if(res.tempFilePath){
						// 发送视频到服务器或者第三方云存储(如:阿里云oss)
						// 成功发送之后 由服务器或者第三方云存储返回信息如
						// 视频地址、视频封面等
						// 得到返回的信息进行页面渲染
						let poster = '';
						let durationFormatted = '';
						let showPoster = false; // 是否显示封面
						// #ifdef H5
						try{
							// h5封面
							poster = await getH5VideoThumbnail(res.tempFilePath);
							durationFormatted = this.formatVideoDuration(res.duration);
							showPoster = true;
						}catch(e){
							console.log('H5获取封面失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						// #ifdef MP
						try{
							// 小程序封面
							poster = res.thumbTempFilePath;
							durationFormatted = this.formatVideoDuration(res.duration);
							showPoster = poster ? true : false;
						}catch(e){
							console.log('小程序获取封面失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						// #ifdef APP
						try{
							// APP封面
							//引入插件
							// if(uni.getSystemInfoSync().platform === "android"){
							// 	// 安卓手机获取封面
							// 	const plug=uni.requireNativePlugin("Html5app-VideoCover");
							// 	plug.setVideoPath({
							// 		'url':res.tempFilePath,
							// 		'time':1,
							// 	},ret=>{
							// 		console.log('安卓手机获取封面的结果',ret);
							// 	});
							// }
							poster = this.videoPlaceHolderPoster;
							durationFormatted = this.formatVideoDuration(res.duration);
						}catch(e){
							console.log('APP封面获取失败',e);
							poster = this.videoPlaceHolderPoster;
							durationFormatted = '0:00';
						}
						// #endif
						
						// 发视频
						this.sendMessage('video',{
							...res,
							videoPath:res.tempFilePath,
							poster:poster,
							duration:durationFormatted,
							showPoster:showPoster,
						});
						
					}
				},
				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
					});
				}
			});
		},
		...
	},
}

# 2. 针对H5端的处理

# ① 数据处理

在文件 /pages/chat/plusIconAction.js

    plusMenus:[ // 加号扩展菜单栏目
		// #ifndef H5
		{ name:"相册照片", icon:"photo", iconType:"uview", eventType:"photo" },
		{ name:"拍照片", icon:"\ue62c", iconType:"custom", eventType:"cameraPhoto" },
		{ name:"拍视频", icon:"\ue682", iconType:"custom", eventType:"cameraVideo" },
		{ name:"相册视频", icon:"\ue66d", iconType:"custom", eventType:"video" },
		// #endif
		// #ifdef H5
		{ name:"照片", icon:"photo", iconType:"uview", eventType:"photo" },
		{ name:"视频", icon:"\ue66d", iconType:"custom", eventType:"video" },
		// #endif
		{ name:"位置", icon:"map", iconType:"uview", eventType:"map" },
		{ name:"我的名片", icon:"\ue69d", iconType:"custom", eventType:"mingpian" },
		// { 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" },
	],

# ② 页面处理

在页面 /pages/chat/chat.nvue

    <!-- 底部聊天输入区域 -->
	<view ...>
		<view class="flex align-center">
			<!-- 是H5端给一个返回按钮 -->
			<!-- #ifdef H5 -->
			<chat-navbar-icon-button
			@click="navigateBack">
				<text class="iconfont font-lg">&#xe618;</text>
			</chat-navbar-icon-button>
			<!-- #endif -->
			<!-- 不是H5端有录音功能 -->
			<!-- #ifndef H5 -->
			<!-- 切换发语音 -->
			<!-- 键盘图标 -->
			<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>
			<!-- #endif -->
			
			<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">
			...
		</view>
	</view>

# 五、加号扩展菜单功能

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

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