Browse Source

!54 uniapp语音消息功能上线

Merge pull request !54 from blue/v_2.0.0
master
blue 2 years ago
committed by Gitee
parent
commit
ab7841d2fd
No known key found for this signature in database GPG Key ID: 173E9B9CA92EEF8F
  1. 3
      README.md
  2. 6
      im-ui/src/assets/iconfont/iconfont.css
  3. BIN
      im-ui/src/assets/iconfont/iconfont.ttf
  4. 4
      im-ui/src/components/chat/ChatBox.vue
  5. 2
      im-ui/src/components/chat/ChatMessageItem.vue
  6. 7
      im-ui/src/components/chat/ChatPrivateVideo.vue
  7. 3
      im-ui/src/components/common/HeadImage.vue
  8. 12
      im-ui/src/store/chatStore.js
  9. 6
      im-ui/src/view/Home.vue
  10. 22
      im-ui/src/view/Login.vue
  11. 4
      im-uniapp/App.vue
  12. 62
      im-uniapp/common/recorder-app.js
  13. 54
      im-uniapp/common/recorder-h5.js
  14. 100
      im-uniapp/components/chat-message-item/chat-message-item.vue
  15. 227
      im-uniapp/components/chat-record/chat-record.vue
  16. 8
      im-uniapp/main.js
  17. 5
      im-uniapp/manifest.json
  18. 10
      im-uniapp/package.json
  19. 94
      im-uniapp/pages/chat/chat-box.vue
  20. 2
      im-uniapp/pages/chat/chat-video.vue
  21. 14
      im-uniapp/static/icon/iconfont.css
  22. BIN
      im-uniapp/static/icon/iconfont.ttf
  23. 12
      im-uniapp/store/chatStore.js

3
README.md

@ -41,7 +41,7 @@
![输入图片说明](%E6%88%AA%E5%9B%BE/wx%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BA%8C%E7%BB%B4%E7%A0%81.jpg) ![输入图片说明](%E6%88%AA%E5%9B%BE/wx%E5%B0%8F%E7%A8%8B%E5%BA%8F%E4%BA%8C%E7%BB%B4%E7%A0%81.jpg)
注:由于每次发布小程序都需要经过严格且繁琐的审核,当前线上微信小程序并非最新版本,最后一次更新时间是2023年12月
#### 相关项目 #### 相关项目
@ -260,5 +260,4 @@ wsApi.onClose((e) => {
1. 本系统允许用于商业用途,且不收费(自愿投币)。**但切记不要用于任何非法用途** ,本软件作者不会为此承担任何责任 1. 本系统允许用于商业用途,且不收费(自愿投币)。**但切记不要用于任何非法用途** ,本软件作者不会为此承担任何责任
1. 基于本系统二次开发后再次开源的项目,请注明引用出处,以避免引发不必要的误会 1. 基于本系统二次开发后再次开源的项目,请注明引用出处,以避免引发不必要的误会
1. 如果您也想体验开源(bei bai piao)的快感,成为本项目的贡献者,欢迎提交PR。开发前最好提前联系作者,避免功能重复开发 1. 如果您也想体验开源(bei bai piao)的快感,成为本项目的贡献者,欢迎提交PR。开发前最好提前联系作者,避免功能重复开发
1. 如果您不具备搭建本系统的能力,作者可以提供付费搭建服务,收费标准:150~200元/次。需自备服务器(必要)、域名和ssl证书(可选)、企业主体小程序账号(可选)

6
im-ui/src/assets/iconfont/iconfont.css

@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: "iconfont"; /* Project id 3791506 */ font-family: "iconfont"; /* Project id 3791506 */
src: url('iconfont.ttf?t=1710567233281') format('truetype'); src: url('iconfont.ttf?t=1711892447736') format('truetype');
} }
.iconfont { .iconfont {
@ -11,6 +11,10 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-exit:before {
content: "\e9e4";
}
.icon-chat-video:before { .icon-chat-video:before {
content: "\e73b"; content: "\e73b";
} }

BIN
im-ui/src/assets/iconfont/iconfont.ttf

Binary file not shown.

4
im-ui/src/components/chat/ChatBox.vue

@ -13,7 +13,7 @@
<div class="im-chat-box"> <div class="im-chat-box">
<ul> <ul>
<li v-for="(msgInfo, idx) in chat.messages" :key="idx"> <li v-for="(msgInfo, idx) in chat.messages" :key="idx">
<chat-message-item v-show="idx >= showMinIdx" <chat-message-item v-if="idx >= showMinIdx"
@call="onCall(msgInfo.type)" @call="onCall(msgInfo.type)"
:mine="msgInfo.sendId == mine.id" :mine="msgInfo.sendId == mine.id"
:headImage="headImage(msgInfo)" :showName="showName(msgInfo)" :msgInfo="msgInfo" :headImage="headImage(msgInfo)" :showName="showName(msgInfo)" :msgInfo="msgInfo"
@ -75,7 +75,7 @@
</div> </div>
</el-footer> </el-footer>
</el-container> </el-container>
<el-aside class="chat-group-side-box" width="300px" v-show="showSide"> <el-aside class="chat-group-side-box" width="300px" v-if="showSide">
<chat-group-side :group="group" :groupMembers="groupMembers" @reload="loadGroup(group.id)"> <chat-group-side :group="group" :groupMembers="groupMembers" @reload="loadGroup(group.id)">
</chat-group-side> </chat-group-side>
</el-aside> </el-aside>

2
im-ui/src/components/chat/ChatMessageItem.vue

@ -28,7 +28,7 @@
<div class="img-load-box" v-loading="loading" element-loading-text="上传中.." <div class="img-load-box" v-loading="loading" element-loading-text="上传中.."
element-loading-background="rgba(0, 0, 0, 0.4)"> element-loading-background="rgba(0, 0, 0, 0.4)">
<img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl" <img class="send-image" :src="JSON.parse(msgInfo.content).thumbUrl"
@click="showFullImageBox()" /> @click="showFullImageBox()" loading="lazy"/>
</div> </div>
<span title="发送失败" v-show="loadFail" @click="onSendFail" <span title="发送失败" v-show="loadFail" @click="onSendFail"
class="send-fail el-icon-warning"></span> class="send-fail el-icon-warning"></span>

7
im-ui/src/components/chat/ChatPrivateVideo.vue

@ -139,7 +139,7 @@
type: 'PRIVATE', type: 'PRIVATE',
targetId: this.rtcInfo.friend.id, targetId: this.rtcInfo.friend.id,
showName: this.rtcInfo.friend.nickName, showName: this.rtcInfo.friend.nickName,
headImage: this.rtcInfo.friend.headImageThumb, headImage: this.rtcInfo.friend.headImage,
}; };
this.$store.commit("openChat", chat); this.$store.commit("openChat", chat);
// //
@ -424,7 +424,8 @@
video { video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: fill; object-fit: cover;
transform: rotateY(180deg);
} }
} }
@ -439,6 +440,8 @@
video { video {
width: 100%; width: 100%;
object-fit: cover;
transform: rotateY(180deg);
} }
} }
} }

3
im-ui/src/components/common/HeadImage.vue

@ -1,6 +1,7 @@
<template> <template>
<div class="head-image" @click="showUserInfo($event)"> <div class="head-image" @click="showUserInfo($event)">
<img class="avatar-image" v-show="url" :src="url" :style="avatarImageStyle" /> <img class="avatar-image" v-show="url" :src="url"
:style="avatarImageStyle" loading="lazy" />
<div class="avatar-text" v-show="!url" :style="avatarTextStyle"> <div class="avatar-text" v-show="!url" :style="avatarTextStyle">
{{name.substring(0,1).toUpperCase()}}</div> {{name.substring(0,1).toUpperCase()}}</div>
<div v-show="online" class="online" title="用户当前在线"></div> <div v-show="online" class="online" title="用户当前在线"></div>

12
im-ui/src/store/chatStore.js

@ -174,8 +174,16 @@ export default {
}); });
chat.lastTimeTip = msgInfo.sendTime; chat.lastTimeTip = msgInfo.sendTime;
} }
// 新的消息 // 根据id顺序插入,防止消息乱序
chat.messages.push(msgInfo); let insertPos = chat.messages.length;
for (let idx in chat.messages) {
if (chat.messages[idx].id && msgInfo.id < chat.messages[idx].id) {
insertPos = idx;
console.log(`消息出现乱序,位置:${chat.messages.length},修正至:${insertPos}`);
break;
}
}
chat.messages.splice(insertPos, 0, msgInfo);
this.commit("saveToStorage"); this.commit("saveToStorage");
}, },
updateMessage(state, msgInfo) { updateMessage(state, msgInfo) {

6
im-ui/src/view/Home.vue

@ -29,7 +29,7 @@
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
<div class="exit-box" @click="onExit()" title="退出"> <div class="exit-box" @click="onExit()" title="退出">
<span class="el-icon-circle-close"></span> <span class="icon iconfont icon-exit"></span>
</div> </div>
</el-aside> </el-aside>
<el-main class="content-box"> <el-main class="content-box">
@ -372,10 +372,12 @@
width: 60px; width: 60px;
bottom: 40px; bottom: 40px;
color: #aaaaaa; color: #aaaaaa;
font-size: 24px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
.icon {
font-size: 28px;
}
&:hover { &:hover {
color: white !important; color: white !important;
} }

22
im-ui/src/view/Login.vue

@ -5,35 +5,31 @@
<div> <div>
<h3>盒子IM 2.0版本已上线</h3> <h3>盒子IM 2.0版本已上线</h3>
<ul> <ul>
<li>加入uniapp移动版本支持移动端和web端同时在线多端消息同步</li> <li>加入uniapp移动,支持移动端和web端同时在线多端消息同步</li>
<li>目前移动端仅兼容h5和微信小程序后续会继续兼容更多终端类型</li> <li>目前uniapp移动端支持安卓iosh5微信小程序</li>
<li>聊天窗口支持粘贴截图@群成员已读未读显示</li> <li>聊天窗口支持粘贴截图@群成员已读未读显示</li>
<li>页面风格升级:表情包更新自动生成文字头像等</li>
<li>支持群聊已读显示(回执消息)</li> <li>支持群聊已读显示(回执消息)</li>
<li>语雀文档 <li>语雀文档
<a href="https://www.yuque.com/u1475064/mufu2a" target="_blank">盒子IM详细介绍文档</a>,目前限时免费开放中 <a href="https://www.yuque.com/u1475064/mufu2a" target="_blank">盒子IM详细介绍文档</a>,目前限时免费开放中
</li> </li>
</ul> </ul>
</div> </div>
<div>
<h3>最近更新(2024-02-24)</h3>
<ul>
<li>uniapp端兼容ios和andriod,
<a href="https://www.boxim.online/download/boxim.apk" target="_blank">点击下载安卓客户端</a>
</li>
<li>uniapp端的启动和打包方式有所变化具体请参考语雀文档</li>
</ul>
</div>
<div> <div>
<h3>最近更新(2024-03-17)</h3> <h3>最近更新(2024-03-17)</h3>
<ul> <ul>
<li>web端音视频功能优化:支持语音呼叫会话中加入通话状态消息</li> <li>web端音视频功能优化:支持语音呼叫会话中加入通话状态消息</li>
<li>uniapp端支持音视频通话并与web端打通</li> <li>uniapp端支持音视频通话并与web端打通</li>
<li>uniapp端音视频源码通话源码暂未开源需付费获取: <li>uniapp端音视频源码通话源码暂未开源需付费获取:
<a href="https://www.yuque.com/u1475064/oncgyg/vi7engzluty594s2" target="_blank">uniapp端音视频通源码购买说明</a> <a href="https://www.yuque.com/u1475064/oncgyg/vi7engzluty594s2" target="_blank">uniapp端音视频通源码购买说明</a>
</li> </li>
</ul> </ul>
</div> </div>
<div>
<h3>最近更新(2024-03-31)</h3>
<ul>
<li>uniapp移动端支持发送语音消息</li>
</ul>
</div>
<div> <div>
<h3>如果本项目对您有帮助,请在gitee上帮忙点个star</h3> <h3>如果本项目对您有帮助,请在gitee上帮忙点个star</h3>
</div> </div>

4
im-uniapp/App.vue

@ -106,6 +106,10 @@
// webrtc // webrtc
if (msg.type >= enums.MESSAGE_TYPE.RTC_CALL_VOICE && if (msg.type >= enums.MESSAGE_TYPE.RTC_CALL_VOICE &&
msg.type <= enums.MESSAGE_TYPE.RTC_CANDIDATE) { msg.type <= enums.MESSAGE_TYPE.RTC_CANDIDATE) {
// #ifdef MP-WEIXIN
//
return;
// #endif
// //
if(msg.type == enums.MESSAGE_TYPE.RTC_CALL_VOICE if(msg.type == enums.MESSAGE_TYPE.RTC_CALL_VOICE
|| msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO){ || msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO){

62
im-uniapp/common/recorder-app.js

@ -0,0 +1,62 @@
import UNI_APP from '@/.env.js';
const rc = uni.getRecorderManager();
// 录音开始时间
let startTime = null;
let start = () => {
return new Promise((resolve, reject) => {
rc.onStart(() => {
startTime = new Date();
resolve()
});
rc.onError((e) => {
console.log(e);
reject(e)
})
rc.start({
format: 'mp3' // 录音格式,可选值:aac/mp3
});
})
}
let pause = () => {
rc.stop();
}
let close = () => {
rc.stop();
}
let upload = () => {
return new Promise((resolve, reject) => {
rc.onStop((wavFile, a, b) => {
uni.uploadFile({
url: UNI_APP.BASE_URL + '/file/upload',
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
filePath: wavFile.tempFilePath,
name: 'file',
success: (res) => {
const duration = (new Date().getTime() - startTime.getTime()) / 1000
const data = {
duration: Math.round(duration),
url: JSON.parse(res.data).data
}
resolve(data);
},
fail: (e) => {
reject(e);
}
})
});
})
}
export {
start,
pause,
close,
upload
}

54
im-uniapp/common/recorder-h5.js

@ -0,0 +1,54 @@
import Recorder from 'js-audio-recorder';
import UNI_APP from '@/.env.js';
let rc = null;
let start = () => {
if(rc != null){
close();
}
rc = new Recorder();
return rc.start();
}
let pause = () => {
rc.pause();
}
let close = () => {
rc.destroy();
rc = null;
}
let upload = () => {
return new Promise((resolve, reject) => {
const wavBlob = rc.getWAVBlob();
const newbolb = new Blob([wavBlob], { type: 'audio/wav'})
const name = new Date().getDate() + '.wav';
const file = new File([newbolb], name)
uni.uploadFile({
url: UNI_APP.BASE_URL + '/file/upload',
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
file: file,
name: 'file',
success: (res) => {
const data = {
duration: parseInt(rc.duration),
url: JSON.parse(res.data).data
}
resolve(data);
},
fail: (e) => {
reject(e);
}
})
})
}
export {
start,
pause,
close,
upload
}

100
im-uniapp/components/chat-message-item/chat-message-item.vue

@ -1,13 +1,16 @@
<template> <template>
<view class="chat-msg-item"> <view class="chat-msg-item">
<view class="chat-msg-tip" v-if="msgInfo.type==$enums.MESSAGE_TYPE.RECALL||msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">{{msgInfo.content}}</view> <view class="chat-msg-tip"
v-if="msgInfo.type==$enums.MESSAGE_TYPE.RECALL||msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">
{{msgInfo.content}}</view>
<view class="chat-msg-tip" v-if="msgInfo.type==$enums.MESSAGE_TYPE.TIP_TIME"> <view class="chat-msg-tip" v-if="msgInfo.type==$enums.MESSAGE_TYPE.TIP_TIME">
{{$date.toTimeText(msgInfo.sendTime)}} {{$date.toTimeText(msgInfo.sendTime)}}
</view> </view>
<view class="chat-msg-normal" v-if="msgInfo.type>=0 && msgInfo.type<10" <view class="chat-msg-normal" v-if="msgInfo.type>=0 && msgInfo.type<10"
:class="{'chat-msg-mine':msgInfo.selfSend}"> :class="{'chat-msg-mine':msgInfo.selfSend}">
<head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage" :name="showName" :size="80"></head-image> <head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage"
:name="showName" :size="80"></head-image>
<view class="chat-msg-content" @longpress="onShowMenu($event)"> <view class="chat-msg-content" @longpress="onShowMenu($event)">
<view v-if="msgInfo.groupId && !msgInfo.selfSend" class="chat-msg-top"> <view v-if="msgInfo.groupId && !msgInfo.selfSend" class="chat-msg-top">
<text>{{showName}}</text> <text>{{showName}}</text>
@ -26,7 +29,6 @@
<text title="发送失败" v-if="loadFail" @click="onSendFail" <text title="发送失败" v-if="loadFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></text> class="send-fail iconfont icon-warning-circle-fill"></text>
</view> </view>
<view class="chat-msg-file" v-if="msgInfo.type==$enums.MESSAGE_TYPE.FILE"> <view class="chat-msg-file" v-if="msgInfo.type==$enums.MESSAGE_TYPE.FILE">
<view class="chat-file-box"> <view class="chat-file-box">
<view class="chat-file-info"> <view class="chat-file-info">
@ -40,31 +42,28 @@
<text title="发送失败" v-if="loadFail" @click="onSendFail" <text title="发送失败" v-if="loadFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></text> class="send-fail iconfont icon-warning-circle-fill"></text>
</view> </view>
<view class="chat-realtime chat-msg-text" v-if="isRTMessage" <view class="chat-msg-audio chat-msg-text" v-if="msgInfo.type==$enums.MESSAGE_TYPE.AUDIO"
@click="$emit('call')"> @click="onPlayAudio()">
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VOICE" <text class="iconfont icon-voice-play"></text>
class="iconfont icon-chat-voice"></text> <text class="chat-audio-text">{{JSON.parse(msgInfo.content).duration+'"'}}</text>
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VIDEO" <text v-if="audioPlayState=='PAUSE'" class="iconfont icon-play"></text>
class="iconfont icon-chat-video"></text> <text v-if="audioPlayState=='PLAYING'" class="iconfont icon-pause"></text>
</view>
<view class="chat-realtime chat-msg-text" v-if="isRTMessage" @click="$emit('call')">
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VOICE" class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type==$enums.MESSAGE_TYPE.RT_VIDEO" class="iconfont icon-chat-video"></text>
<text>{{msgInfo.content}}</text> <text>{{msgInfo.content}}</text>
</view> </view>
<view class="chat-msg-status" v-if="!isRTMessage"> <view class="chat-msg-status" v-if="!isRTMessage">
<text class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId <text class="chat-readed" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status==$enums.MESSAGE_STATUS.READED">已读</text> && msgInfo.status==$enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId <text class="chat-unread" v-show="msgInfo.selfSend && !msgInfo.groupId
&& msgInfo.status!=$enums.MESSAGE_STATUS.READED">未读</text> && msgInfo.status!=$enums.MESSAGE_STATUS.READED">未读</text>
</view> </view>
<view class="chat-receipt" v-show="msgInfo.receipt" @click="onShowReadedBox"> <view class="chat-receipt" v-show="msgInfo.receipt" @click="onShowReadedBox">
<text v-if="msgInfo.receiptOk" class="tool-icon iconfont icon-ok"></text> <text v-if="msgInfo.receiptOk" class="tool-icon iconfont icon-ok"></text>
<text v-else>{{msgInfo.readedCount}}人已读</text> <text v-else>{{msgInfo.readedCount}}人已读</text>
</view> </view>
<!--
<view class="chat-msg-voice" v-if="msgInfo.type==$enums.MESSAGE_TYPE.AUDIO" @click="onPlayVoice()">
<audio controls :src="JSON.parse(msgInfo.content).url"></audio>
</view>
-->
</view> </view>
</view> </view>
</view> </view>
@ -97,6 +96,7 @@
data() { data() {
return { return {
audioPlayState: 'STOP', audioPlayState: 'STOP',
innerAudioContext: null,
menu: { menu: {
show: false, show: false,
style: "" style: ""
@ -135,13 +135,31 @@
icon: "none" icon: "none"
}) })
}, },
onPlayVoice() { onPlayAudio() {
if (!this.audio) { //
this.audio = new Audio(); if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
let url = JSON.parse(this.msgInfo.content).url;
this.innerAudioContext.src = url;
this.innerAudioContext.onEnded((e) => {
console.log('停止')
this.audioPlayState = "STOP"
})
this.innerAudioContext.onError((e) =>{
console.log("播放音频出错");
console.log(e)
});
}
if (this.audioPlayState == 'STOP') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING";
} else if (this.audioPlayState == 'PLAYING') {
this.innerAudioContext.pause();
this.audioPlayState = "PAUSE"
} else if (this.audioPlayState == 'PAUSE') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING"
} }
this.audio.src = JSON.parse(this.msgInfo.content).url;
this.audio.play();
this.handlePlayVoice = 'RUNNING';
}, },
onSelectMenu(item) { onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo); this.$emit(item.key.toLowerCase(), this.msgInfo);
@ -357,12 +375,24 @@
} }
.chat-msg-audio {
display: flex;
align-items: center;
.chat-audio-text {
padding-right: 8px;
}
.icon-voice-play {
font-size: 20px;
padding-right: 8px;
}
}
.chat-realtime { .chat-realtime {
display: flex; display: flex;
align-items: center; align-items: center;
.iconfont { .iconfont {
font-size: 20px; font-size: 20px;
padding-right: 8px; padding-right: 8px;
@ -373,13 +403,13 @@
display: block; display: block;
.chat-readed { .chat-readed {
font-size: 10px; font-size: 12px;
color: #ccc; color: #888;
font-weight: 600; font-weight: 600;
} }
.chat-unread { .chat-unread {
font-size: 10px; font-size: 12px;
color: #f23c0f; color: #f23c0f;
font-weight: 600; font-weight: 600;
} }
@ -436,14 +466,28 @@
.chat-msg-file { .chat-msg-file {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.chat-msg-audio {
flex-direction: row-reverse;
.chat-audio-text {
padding-right: 0;
padding-left: 8px;
}
.icon-voice-play {
transform: rotateY(180deg);
}
}
.chat-realtime { .chat-realtime {
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
.iconfont { .iconfont {
transform: rotateY(180deg); transform: rotateY(180deg);
} }
} }
} }
} }
} }

227
im-uniapp/components/chat-record/chat-record.vue

@ -0,0 +1,227 @@
<template>
<view class="chat-record">
<view class="chat-record-bar" id="chat-record-bar" :style="recordBarStyle" @touchstart="onStartRecord"
@touchmove="onTouchMove" @touchend.prevent="onEndRecord">{{recording?'正在录音':'长按 说话'}}</view>
<view v-if="recording" class="chat-record-window" :style="recordWindowStyle">
<view class="rc-wave">
<text class="note" style="--d: 0"></text>
<text class="note" style="--d: 1"></text>
<text class="note" style="--d: 2"></text>
<text class="note" style="--d: 3"></text>
<text class="note" style="--d: 4"></text>
<text class="note" style="--d: 5"></text>
<text class="note" style="--d: 6"></text>
</view>
<view class="rc-tip">{{recordTip}}</view>
<view class="cancel-btn">
<uni-icons :class="moveToCancel?'red':'black'" type="clear"
:size="moveToCancel?45:40"></uni-icons>
</view>
<view class="opt-tip" :class="moveToCancel?'red':'black'">{{moveToCancel? '松手取消':'松手发送,上划取消'}}</view>
</view>
</view>
</template>
<script>
export default {
name: "chat-record",
data() {
return {
recording: false,
moveToCancel: false,
recordBarTop: 0,
druation: 0,
rcTimer: null
}
},
methods: {
onTouchMove(e) {
const moveY = e.touches[0].clientY;
this.moveToCancel = moveY < this.recordBarTop-40;
},
onStartRecord() {
console.log("开始录音")
this.moveToCancel = false;
this.initRecordBar();
this.$rc.start().then(() => {
this.recording = true;
console.log("开始录音成功")
//
this.startTimer();
}).catch((e) => {
console.log("录音失败"+JSON.stringify(e))
uni.showToast({
title: "录音失败",
icon: "none"
});
});
},
onEndRecord() {
this.recording = false;
//
this.$rc.pause();
//
this.StopTimer();
//
if (this.moveToCancel) {
this.$rc.close();
console.log("录音取消")
return;
}
// 1
if (this.druation == 0) {
uni.showToast({
title: "说话时间太短",
icon: 'none'
})
this.$rc.close();
return;
}
this.$rc.upload().then((data) => {
this.$emit("send", data);
}).catch((e) => {
uni.showToast({
title: e,
icon: 'none'
})
}).finally(() => {
this.$rc.close();
console.log("录音完成")
})
},
startTimer() {
this.druation = 0;
this.StopTimer();
this.rcTimer = setInterval(() => {
this.druation++;
// 60s,
if(this.druation >= 60 ){
this.onEndRecord();
}
}, 1000)
},
StopTimer() {
this.rcTimer && clearInterval(this.rcTimer);
this.rcTimer = null;
},
initRecordBar() {
const query = uni.createSelectorQuery().in(this);
query.select('#chat-record-bar').boundingClientRect((rect) => {
//
this.recordBarTop = rect.top;
}).exec()
}
},
computed: {
recordWindowStyle() {
const windowHeight = uni.getSystemInfoSync().windowHeight;
const bottom = windowHeight - this.recordBarTop + 12;
return `bottom:${bottom}px;`
},
recordBarStyle() {
const bgColor = this.recording ? "royalblue" : "#f8f8f8";
return `background-color:${bgColor};`
},
recordTip(){
if(this.druation > 50){
return `${60-this.druation}s后将停止录音`;
}
return `录音时长:${this.druation}s`;
}
}
}
</script>
<style lang="scss" scoped>
.chat-record {
.rc-wave {
display: flex;
align-items: flex-end;
justify-content: center;
position: relative;
height: 80rpx;
.note {
background: linear-gradient(to top, #395ff3 0%, #89aff3 100%);
width: 4px;
height: 50%;
border-radius: 5rpx;
margin-right: 4px;
animation: loading 0.5s infinite linear;
animation-delay: calc(0.1s * var(--d));
@keyframes loading {
0% {
background-image: linear-gradient(to right, #395ff3 0%, #89aff3 100%);
height: 20%;
border-radius: 5rpx;
}
50% {
background-image: linear-gradient(to top, #395ff3 0%, #a9cff3 100%);
height: 80%;
border-radius: 5rpx;
}
100% {
background-image: linear-gradient(to top, #395ff3 0%, #a9cff3 100%);
height: 20%;
border-radius: 5rpx;
}
}
}
}
.chat-record-bar {
padding: 10rpx;
margin: 10rpx;
border-radius: 10rpx;
text-align: center;
}
.chat-record-window {
position: fixed;
left: 0;
height: 360rpx;
width: 100%;
background-color: rgba(255, 255, 255, 0.95);
padding: 30rpx;
.icon-microphone {
text-align: center;
font-size: 80rpx;
padding: 10rpx;
}
.rc-tip {
text-align: center;
font-size: 30rpx;
margin-top: 20rpx;
}
.cancel-btn {
text-align: center;
margin-top: 40rpx;
height: 80rpx;
}
.opt-tip {
text-align: center;
font-size: 30rpx;
padding: 20rpx;
}
.red {
color: red !important;
}
.black {
color: gray;
}
}
}
</style>

8
im-uniapp/main.js

@ -6,6 +6,13 @@ import * as date from './common/date';
import * as socketApi from './common/wssocket'; import * as socketApi from './common/wssocket';
import store from './store'; import store from './store';
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
// #ifdef H5
import * as recorder from './common/recorder-h5';
// #endif
// #ifndef H5
import * as recorder from './common/recorder-app';
// #endif
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
@ -15,6 +22,7 @@ export function createApp() {
app.config.globalProperties.$emo = emotion; app.config.globalProperties.$emo = emotion;
app.config.globalProperties.$enums = enums; app.config.globalProperties.$enums = enums;
app.config.globalProperties.$date = date; app.config.globalProperties.$date = date;
app.config.globalProperties.$rc = recorder;
return { return {
app app
} }

5
im-uniapp/manifest.json

@ -2,8 +2,8 @@
"name" : "盒子IM", "name" : "盒子IM",
"appid" : "__UNI__69DD57A", "appid" : "__UNI__69DD57A",
"description" : "", "description" : "",
"versionName" : "1.0.6", "versionName" : "1.0.7",
"versionCode" : 106, "versionCode" : 107,
"transformPx" : false, "transformPx" : false,
/* 5+App */ /* 5+App */
"app-plus" : { "app-plus" : {
@ -100,6 +100,7 @@
/* */ /* */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wxda94f40bfad0262c", "appid" : "wxda94f40bfad0262c",
"libVersion": "latest",
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false
}, },

10
im-uniapp/package.json

@ -1,6 +1,10 @@
{ {
"name": "盒子IM",
"uni-app": { "uni-app": {
"scripts": { "scripts": {}
} },
"dependencies": {
"js-audio-recorder": "^1.0.7",
"recorder-core": "^1.3.23122400"
} }
} }

94
im-uniapp/pages/chat/chat-box.vue

@ -9,8 +9,7 @@
<scroll-view class="scroll-box" scroll-y="true" @scrolltoupper="onScrollToTop" <scroll-view class="scroll-box" scroll-y="true" @scrolltoupper="onScrollToTop"
:scroll-into-view="'chat-item-'+scrollMsgIdx"> :scroll-into-view="'chat-item-'+scrollMsgIdx">
<view v-for="(msgInfo,idx) in chat.messages" :key="idx"> <view v-for="(msgInfo,idx) in chat.messages" :key="idx">
<chat-message-item v-if="idx>=showMinIdx" :headImage="headImage(msgInfo)" <chat-message-item v-if="idx>=showMinIdx" :headImage="headImage(msgInfo)" @call="onRtCall(msgInfo)"
@call="onRtCall(msgInfo)"
:showName="showName(msgInfo)" @recall="onRecallMessage" @delete="onDeleteMessage" :showName="showName(msgInfo)" @recall="onRecallMessage" @delete="onDeleteMessage"
@longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile" :id="'chat-item-'+idx" @longPressHead="onLongPressHead(msgInfo)" @download="onDownloadFile" :id="'chat-item-'+idx"
:msgInfo="msgInfo" :groupMembers="groupMembers"> :msgInfo="msgInfo" :groupMembers="groupMembers">
@ -29,15 +28,18 @@
</scroll-view> </scroll-view>
</view> </view>
<view class="send-bar"> <view class="send-bar">
<view class="send-text"> <view v-if="!showRecord" class="iconfont icon-voice-circle" @click="onRecorderInput()"></view>
<view v-else class="iconfont icon-keyboard" @click="onKeyboardInput()"></view>
<chat-record v-if="showRecord" class="chat-record" @send="onSendRecord" ></chat-record>
<view v-else class="send-text">
<textarea class="send-text-area" v-model="sendText" auto-height :show-confirm-bar="false" <textarea class="send-text-area" v-model="sendText" auto-height :show-confirm-bar="false"
:placeholder="isReceipt?'[回执消息]':''" :adjust-position="false" @confirm="sendTextMessage()" :placeholder="isReceipt?'[回执消息]':''" :adjust-position="false" @confirm="sendTextMessage()"
@keyboardheightchange="onKeyboardheightchange" @input="onTextInput" confirm-type="send" confirm-hold @keyboardheightchange="onKeyboardheightchange" @input="onTextInput" confirm-type="send" confirm-hold
:hold-keyboard="true"></textarea> :hold-keyboard="true"></textarea>
</view> </view>
<view v-if="chat.type=='GROUP'" class="iconfont icon-at" @click="openAtBox()"></view> <view v-if="chat.type=='GROUP'" class="iconfont icon-at" @click="openAtBox()"></view>
<view class="iconfont icon-icon_emoji" @click="switchChatTabBox('emo',true)"></view> <view class="iconfont icon-icon_emoji" @click="onShowEmoChatTab()"></view>
<view v-if="sendText==''" class="iconfont icon-add" @click="switchChatTabBox('tools',true)"> <view v-if="sendText==''" class="iconfont icon-add" @click="onShowToolsChatTab()">
</view> </view>
<button v-if="sendText!=''||atUserIds.length>0" class="btn-send" type="primary" <button v-if="sendText!=''||atUserIds.length>0" class="btn-send" type="primary"
@touchend.prevent="sendTextMessage()" size="mini">发送</button> @touchend.prevent="sendTextMessage()" size="mini">发送</button>
@ -69,22 +71,25 @@
<view class="tool-name">文件</view> <view class="tool-name">文件</view>
</view> </view>
<!-- #endif --> <!-- #endif -->
<view class="chat-tools-item" @click="showTip()"> <view class="chat-tools-item" @click="onVoiceInput()">
<view class="tool-icon iconfont icon-microphone"></view> <view class="tool-icon iconfont icon-microphone"></view>
<view class="tool-name">语音输入</view> <view class="tool-name">语音消息</view>
</view> </view>
<view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="switchReceipt()"> <view v-if="chat.type == 'GROUP'" class="chat-tools-item" @click="switchReceipt()">
<view class="tool-icon iconfont icon-receipt" :class="isReceipt?'active':''"></view> <view class="tool-icon iconfont icon-receipt" :class="isReceipt?'active':''"></view>
<view class="tool-name">回执消息</view> <view class="tool-name">回执消息</view>
</view> </view>
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVideoCall()"> <!-- #ifndef MP-WEIXIN -->
<!-- 音视频不支持小程序 -->
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVideoCall()">
<view class="tool-icon iconfont icon-video"></view> <view class="tool-icon iconfont icon-video"></view>
<view class="tool-name">视频通话</view> <view class="tool-name">视频通话</view>
</view> </view>
<view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVoiceCall()"> <view v-if="chat.type == 'PRIVATE'" class="chat-tools-item" @click="onVoiceCall()">
<view class="tool-icon iconfont icon-call"></view> <view class="tool-icon iconfont icon-call"></view>
<view class="tool-name">语音通话</view> <view class="tool-name">语音通话</view>
</view> </view>
<!-- #endif -->
</view> </view>
<scroll-view v-if="chatTabBox==='emo'" class="chat-emotion" scroll-y="true"> <scroll-view v-if="chatTabBox==='emo'" class="chat-emotion" scroll-y="true">
<view class="emotion-item-list"> <view class="emotion-item-list">
@ -101,6 +106,7 @@
</template> </template>
<script> <script>
import UNI_APP from '@/.env.js';
export default { export default {
data() { data() {
return { return {
@ -110,36 +116,68 @@
groupMembers: [], groupMembers: [],
sendText: "", sendText: "",
isReceipt: false, // isReceipt: false, //
showVoice: false, //
scrollMsgIdx: 0, // scrollMsgIdx: 0, //
chatTabBox: 'none', chatTabBox: 'none',
showKeyBoard: false, showKeyBoard: false,
showRecord: false,
keyboardHeight: 322, keyboardHeight: 322,
atUserIds: [], atUserIds: [],
recordText: "",
showMinIdx: 0 // showMinIdx showMinIdx: 0 // showMinIdx
} }
}, },
methods: { methods: {
showTip() { onRecorderInput() {
uni.showToast({ this.showRecord = true;
title: "暂未支持...", this.switchChatTabBox('none',true);
icon: "none" },
onKeyboardInput() {
this.showRecord = false;
this.switchChatTabBox('none',false);
},
onSendRecord(data) {
console.log(data);
let msgInfo = {
content: JSON.stringify(data),
type: this.$enums.MESSAGE_TYPE.AUDIO,
receipt: this.isReceipt
}
// id
this.fillTargetId(msgInfo, this.chat.targetId);
this.$http({
url: this.messageAction,
method: 'POST',
data: msgInfo
}).then((id) => {
msgInfo.id = id;
msgInfo.sendTime = new Date().getTime();
msgInfo.sendId = this.$store.state.userStore.userInfo.id;
msgInfo.selfSend = true;
msgInfo.status = this.$enums.MESSAGE_STATUS.UNSEND;
msgInfo.readedCount = 0;
this.$store.commit("insertMessage", msgInfo);
//
this.moveChatToTop();
//
this.scrollToBottom();
this.isReceipt = false;
}) })
}, },
onRtCall(msgInfo){ onRtCall(msgInfo) {
if(msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE){ if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VOICE) {
this.onVoiceCall(); this.onVoiceCall();
}else if(msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO){ } else if (msgInfo.type == this.$enums.MESSAGE_TYPE.RT_VIDEO) {
this.onVideoCall(); this.onVideoCall();
} }
}, },
onVideoCall(){ onVideoCall() {
const friendInfo = encodeURIComponent(JSON.stringify(this.friend)); const friendInfo = encodeURIComponent(JSON.stringify(this.friend));
uni.navigateTo({ uni.navigateTo({
url: `/pages/chat/chat-video?mode=video&friend=${friendInfo}&isHost=true` url: `/pages/chat/chat-video?mode=video&friend=${friendInfo}&isHost=true`
}) })
}, },
onVoiceCall(){ onVoiceCall() {
const friendInfo = encodeURIComponent(JSON.stringify(this.friend)); const friendInfo = encodeURIComponent(JSON.stringify(this.friend));
uni.navigateTo({ uni.navigateTo({
url: `/pages/chat/chat-video?mode=voice&friend=${friendInfo}&isHost=true` url: `/pages/chat/chat-video?mode=voice&friend=${friendInfo}&isHost=true`
@ -264,10 +302,19 @@
}); });
}, },
onShowEmoChatTab(){
this.showRecord = false;
this.switchChatTabBox('emo',true)
},
onShowToolsChatTab(){
this.showRecord = false;
this.switchChatTabBox('tools',true)
},
switchChatTabBox(chatTabBox, hideKeyBoard) { switchChatTabBox(chatTabBox, hideKeyBoard) {
this.chatTabBox = chatTabBox; this.chatTabBox = chatTabBox;
if (hideKeyBoard) { if (hideKeyBoard) {
uni.hideKeyboard(); uni.hideKeyboard();
this.showKeyBoard = false;
} }
}, },
selectEmoji(emoText) { selectEmoji(emoText) {
@ -721,6 +768,11 @@
margin: 3rpx; margin: 3rpx;
} }
.chat-record {
flex: 1;
}
.send-text { .send-text {
flex: 1; flex: 1;
background-color: #f8f8f8 !important; background-color: #f8f8f8 !important;
@ -728,8 +780,6 @@
padding: 20rpx; padding: 20rpx;
background-color: #fff; background-color: #fff;
border-radius: 20rpx; border-radius: 20rpx;
max-height: 300rpx;
min-height: 85rpx;
font-size: 30rpx; font-size: 30rpx;
box-sizing: border-box; box-sizing: border-box;

2
im-uniapp/pages/chat/chat-video.vue

@ -25,7 +25,7 @@
type: 'PRIVATE', type: 'PRIVATE',
targetId: this.friend.id, targetId: this.friend.id,
showName: this.friend.nickName, showName: this.friend.nickName,
headImage: this.friend.headImageThumb, headImage: this.friend.headImage,
}; };
this.$store.commit("openChat",chat); this.$store.commit("openChat",chat);
this.$store.commit("insertMessage", msgInfo); this.$store.commit("insertMessage", msgInfo);

14
im-uniapp/static/icon/iconfont.css

@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: "iconfont"; /* Project id 4272106 */ font-family: "iconfont"; /* Project id 4272106 */
src: url('iconfont.ttf?t=1710556421604') format('truetype'); src: url('iconfont.ttf?t=1711870080646') format('truetype');
} }
.iconfont { .iconfont {
@ -11,6 +11,18 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-pause:before {
content: "\e669";
}
.icon-play:before {
content: "\e620";
}
.icon-voice-play:before {
content: "\e675";
}
.icon-chat-video:before { .icon-chat-video:before {
content: "\e73b"; content: "\e73b";
} }

BIN
im-uniapp/static/icon/iconfont.ttf

Binary file not shown.

12
im-uniapp/store/chatStore.js

@ -176,8 +176,16 @@ export default {
}); });
chat.lastTimeTip = msgInfo.sendTime; chat.lastTimeTip = msgInfo.sendTime;
} }
// 新的消息 // 根据id顺序插入,防止消息乱序
chat.messages.push(msgInfo); let insertPos = chat.messages.length;
for (let idx in chat.messages) {
if (chat.messages[idx].id && msgInfo.id < chat.messages[idx].id) {
insertPos = idx;
console.log(`消息出现乱序,位置:${chat.messages.length},修正至:${insertPos}`);
break;
}
}
chat.messages.splice(insertPos, 0, msgInfo);
this.commit("saveToStorage"); this.commit("saveToStorage");
}, },
updateMessage(state, msgInfo) { updateMessage(state, msgInfo) {

Loading…
Cancel
Save