# 一、消息页视频播放封面开发
素材准备,给大家提供一下视频素材和封面,大家也可以用自己的素材测试
- 视频素材:
https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/video/videoDemo.mp4 - 视频封面素材:
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"></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. 新建页面及配置
- 新建页面
/pages/videoShow/videoShow.nvue - 配置页面
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方法将页面代码分离一部分处理
- 新建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}`;
},
},
}
- 页面
/pages/chat/chat.nvue
<template>
<view>
<!-- 导航栏 -->
<chat-navbar title="聊天" :fixed="true"
:showPlus="false" :showUser="false"
:showBack="true" navbarClass="bg-light"
:h5WeiXinNeedNavbar="h5WeiXinNeedNavbar">
<chat-navbar-icon-button slot="right"
@click="openMore" >
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
</chat-navbar>
<!-- 聊天内容区域 -->
<scroll-view scroll-y class="bg-light position-fixed left-0 right-0"
:style="chatContentStyle" :show-scrollbar="false" @scroll="onScroll"
:scroll-into-view="scrollIntoViewId" >
<!-- 对话部分 -->
<view v-for="(item,index) in chatDataList" :key="index"
:id="'chat-item-'+index">
<chat-item :item="item" :index="index"
:prevTime="index>0 ? chatDataList[index-1].chat_time : 0"
ref="chatItem" @previewImages="previewImages"></chat-item>
</view>
</scroll-view>
<!-- 针对我们的app端点击聊天区域授权加号扩展菜单 -->
<!-- #ifdef APP -->
<view v-if="sendMessageMode == 'plus' || sendMessageMode == 'icon'"
class="position-fixed left-0 right-0"
:style="chatContentStyle"
@click="scrollViewClick"></view>
<!-- #endif -->
<!-- 底部聊天输入区域 --><!-- 修改:添加ref获取textarea实例 -->
<view class="position-fixed bottom-0 border-top
flex flex-row align-center justify-between"
style="background-color: #f7f7f7;width: 750rpx;
min-height: 90rpx;max-height: 320rpx;
padding-top: 12rpx;"
:style="chatBottomStyle">
<view class="flex align-center">
<!-- 切换发语音 -->
<!-- 键盘图标 -->
<chat-navbar-icon-button v-if="sendMessageMode == 'audio'"
@click="changeAudioOrText('text')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<!-- 语音图标 -->
<chat-navbar-icon-button v-else
@click="changeAudioOrText('audio')">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<view class="flex align-center font-sm px-2 py-1 border rounded"
:class="[recorderStatus ? 'bg-hover-light' : 'bg-white']">
<!-- 发语音 按住说话 -->
<view v-if="sendMessageMode == 'audio'"
class="flex flex-row align-center justify-center rounded"
style="width: 440rpx;height: 60rpx;"
@touchstart="recorderTouchstart"
@touchend="recorderTouchend"
@touchcancel="recorderTouchcancel"
@touchmove="recorderTouchmove"
>
<text class="font mr-2" v-if="!recorderStatus">按住</text>
<text class="font" v-if="!recorderStatus">说话</text>
<text class="font mr-2" v-if="recorderStatus">松开</text>
<text class="font" v-if="recorderStatus">发送</text>
</view>
<!-- 发文字 -->
<textarea v-else
ref="textarea" fixed auto-height :maxlength="-1"
style="width: 440rpx;min-height: 60rpx;
max-height: 274rpx;overflow-y: scroll;
text-align: justify;"
:adjust-position="false"
v-model="messageValue"
@focus="textareaFocus"
@input="onTextareaInput"
@blur="onTextareaBlur" ><!-- 新增:监听失焦事件 --><!-- 新增:监听输入事件 -->
</textarea>
</view>
</view>
<view class="flex align-center">
<chat-navbar-icon-button v-if="!messageValue"
@click="openIcon">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<chat-navbar-icon-button v-if="!messageValue"
@click="openPlus">
<text class="iconfont font-lg"></text>
</chat-navbar-icon-button>
<view v-if="messageValue"
class="rounded bg-success px-2 py-1 mr-4"
hover-class="bg-hover-success"
@click="sendMessage('text')">
<text class="font text-white">发送</text>
</view>
</view>
</view>
<!-- 弹出菜单 --><!-- 主要修改区域 -->
<chat-tooltip ref="tooltipPlus" :mask="chatTooltipMask"
:maskTransparent="true" :isBottom="true"
:tooltipWidth="750"
:tooltipHeight="tooltipHeight"
transformOrigin = "center bottom"
tooltipClass ="bg-light border-0 rounded-0"
@hideTooltip="hideTooltip">
<view class="border-top border-light-secondary">
<!-- 表情菜单 -->
<swiper v-if="sendMessageMode === 'icon'"
:indicator-dots="groupedIconMenus.length > 1"
:duration="1000"
:style="tooltipPlusMenuStyle"
@change="handleSwiperChange"><!-- 添加分页切换事件 -->
<!-- 第一页:emoji表情(滚动显示) -->
<swiper-item v-if="emojiPageItems.length > 0">
<scroll-view scroll-y style="height: 100%;">
<view class="flex flex-row flex-wrap justify-start">
<view v-for="(item,itemIndex) in emojiPageItems"
:key="itemIndex"
class="flex flex-column justify-center align-center"
style="width: 12.5%; height: 120rpx; padding: 10rpx;"
@click="insertEmoji(item)">
<text style="font-size: 50rpx;">{{item.icon}}</text>
</view>
</view>
</scroll-view>
</swiper-item>
<!-- 其他类型表情分页显示 -->
<swiper-item v-for="(page,pageIndex) in otherPages"
:key="pageIndex">
<view class="flex flex-row flex-wrap justify-start">
<view v-for="(item,itemIndex) in page"
:key="pageIndex+itemIndex"
class="col-3 flex flex-column justify-center align-center"
style="height: 260rpx;"
@click="item ? swiperItemClick(item,itemIndex) : null"
><!-- 小程序添加空值检查 -->
<view class="bg-white rounded-lg flex flex-row
align-center justify-center mb-2"
style="width:120rpx;height: 120rpx;">
<u--image v-if="item.iconType == 'image'"
:src="item.icon" mode="aspectFit"
width="120rpx" height="120rpx" radius="0rpx"></u--image>
<text v-if="item.iconType == 'emoji'"
style="font-size: 40px;color: #222222;">{{item.icon}}</text>
<text v-if="item.iconType == 'custom'"
class="iconfont"
style="font-size: 26px;color: #222222;">{{item.icon}}</text>
<u-icon v-if="item.iconType == 'uview'"
:name="item.icon" color="#222222" size="26"></u-icon>
</view>
<text class="font-sm text-light-muted">{{item.name}}</text>
</view>
</view>
</swiper-item>
</swiper>
<!-- 加号菜单(保持不变) -->
<swiper v-else-if="sendMessageMode === 'plus'"
:indicator-dots="pageCount > 1" :duration="1000"
:style="tooltipPlusMenuStyle">
<swiper-item v-for="(page,index) in groupedPlusMenus" :key="index">
<view class="flex flex-row justify-start flex-wrap"
:style="swiperItemStyle">
<view class="col-3 flex flex-column justify-center align-center"
style="height: 260rpx;"
v-for="(item,itemIndex) in page" :key="itemIndex"
@click="swiperItemClick(item,itemIndex)">
<view class="bg-white rounded-lg
flex flex-row align-center justify-center mb-2"
style="width:120rpx;height: 120rpx;">
<u--image v-if="item.iconType == 'image'"
:src="item.icon" mode="aspectFit"
width="120rpx" height="120rpx" radius="0rpx"></u--image>
<text v-if="item.iconType == 'emoji'"
style="font-size: 40px;color: #222222;">{{item.icon}}</text>
<text v-if="item.iconType == 'custom'"
class="iconfont"
style="font-size: 26px;color: #222222;">{{item.icon}}</text>
<u-icon v-if="item.iconType == 'uview'"
:name="item.icon" color="#222222" size="26"></u-icon>
</view>
<text class="font-sm text-light-muted">{{item.name}}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>
</chat-tooltip>
<!-- 提示用户正在录音的界面 -->
<view v-if="recorderStatus"
class="position-fixed left-0 right-0 flex flex-row align-center justify-center"
:style="chatContentStyle">
<view style="width: 400rpx;height: 400rpx;background-color: rgba(0, 0, 0, 0.6);"
class="rounded-lg flex flex-column align-center justify-center">
<image :src="recorderIcon" style="width: 300rpx;height: 300rpx;"></image>
<text class="font-sm text-white mt-1">{{isRecorderCancel ? '松开手指 取消发送'
: '已录' + recorderDuration + '秒 手指上划 取消发送'}}</text>
</view>
</view>
</view>
</template>
<script>
import toolJs from '@/common/mixins/tool.js';
import 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.createVideoContext和canvas组合方案(页面组件)
① 小程序端封面获取方法
// 小程序端封面获取方法 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 中添加 tempVideoPathdata(){ 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.createVideoContext和canvas组合方案(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+App的plus.io.getVideoInfohttps://www.html5plus.org/doc/zh_cn/io.html#plus.io.getVideoInfo (opens new window)但是在某些机型上没有返回封面图信息,然后在使用APP的Webview环境中执行H5封面生成方法使用plus.webview和plus.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) ,但是有些插件好用却存在一些问题
- 比如,好用的插件收费(不收费的插件也有好用的,但需要自己找一下)
- 如果插件下架,则影响到项目(虽然这种可能性很小)
在线插件需要绑定包名,即需要申请安卓和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"></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"></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"></text>
</chat-navbar-icon-button>
<!-- 语音图标 -->
<chat-navbar-icon-button v-else
@click="changeAudioOrText('audio')">
<text class="iconfont font-lg"></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>
# 五、加号扩展菜单功能
内容过多,在新页面打开,具体查看: