提交发送语音

This commit is contained in:
2025-07-04 22:34:07 +08:00
parent 2cf13f673d
commit ad33895b6d
10 changed files with 571 additions and 94 deletions

4
.idea/vcs.xml generated
View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -365,7 +365,6 @@ export default {
isLogStandardOutput: true,
isExternalExtensions: false,
});
console.log(flag);
if (!flag) {
plus.navigator.closeSplashscreen();
uni.$u.toast("初始化IMSDK失败");

View File

@@ -0,0 +1,55 @@
<template>
<view class="emoji-picker">
<view class="emoji-list">
<text
v-for="(emoji, idx) in emojis"
:key="idx"
class="emoji-item"
@click="selectEmoji(emoji)"
>{{ emoji }}</text>
</view>
</view>
</template>
<script>
import emojis from '@/common/emojis.js';
export default {
name: 'EmojiPicker',
data() {
return {
emojis
};
},
methods: {
selectEmoji(emoji) {
this.$emit('select', emoji);
}
}
};
</script>
<style scoped>
.emoji-picker {
background: #fff;
border-radius: 12rpx;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
padding: 16rpx;
width: 100%;
}
.emoji-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.emoji-item {
font-size: 48rpx;
padding: 12rpx;
cursor: pointer;
user-select: none;
transition: background 0.2s;
border-radius: 6rpx;
}
.emoji-item:active {
background: #f0f2f6;
}
</style>

View File

@@ -4,59 +4,119 @@
<view class="chat_footer">
<view class="footer_action_area">
<view class="input_content">
<CustomEditor
class="custom_editor"
ref="customEditor"
@ready="editorReady"
@focus="editorFocus"
@blur="editorBlur"
@input="editorInput"
<image v-if="inputBox"
@click.prevent="updateInput"
src="@/static/images/chating_footer_recording.png"
alt=""
srcset=""
/>
<image v-if="!inputBox"
@click.prevent="updateInput"
src="@/static/images/chating_footer_audio_recording.png"
alt=""
srcset=""
/>
</view>
<view class="input_content" v-if="inputBox">
<CustomEditor class="custom_editor" ref="customEditor" @ready="editorReady" @focus="editorFocus"
@blur="editorBlur" @input="editorInput"/>
</view>
<view class="input_content"
v-if="!inputBox"
style="position:relative;"
>
<!-- 录音提示区域 -->
<view v-if="isRecording"
style="position:absolute;left:50%;top:-120rpx;transform:translateX(-50%);z-index:10;display:flex;flex-direction:column;align-items:center;">
<view :style="{
background: willCancel ? 'rgba(255,80,80,0.95)' : 'rgba(0,0,0,0.85)',
color: '#fff',
borderRadius: '12rpx',
padding: '18rpx 32rpx',
fontSize: '28rpx',
minWidth: '260rpx',
textAlign: 'center',
marginBottom: '10rpx',
}">
<text v-if="willCancel">松开手指取消发送</text>
<text v-else>正在讲话已录制 {{recordingTime}} 松开发送</text>
</view>
</view>
<!-- 蒙版 -->
<view v-if="isRecording" style="position:absolute;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.08);z-index:5;border-radius:16rpx;"></view>
<button
class="record_btn"
@touchstart="startRecord"
@touchend="stopRecord"
@touchcancel="cancelRecord"
@touchmove="moveRecord"
style="width:100%;height:100%;background:transparent;border:none;outline:none;display:flex;align-items:center;justify-content:center;z-index:20;position:relative;"
>
<text v-if="!isRecording">按住说话</text>
<text v-else :style="willCancel ? 'color:#ff5050;' : 'color:#4a9cfc;'">{{ willCancel ? '松开手指,取消发送' : '正在说话,松开发送' }}</text>
</button>
</view>
<view class="footer_action_area">
<!-- <image-->
<!-- v-show="!hasContent"-->
<!-- @click.prevent="updateActionBar"-->
<!-- src="@/static/images/chating_footer_add.png"-->
<!-- alt=""-->
<!-- srcset=""-->
<!-- />-->
<image
v-show="hasContent"
@touchend.prevent="sendTextMessage"
src="@/static/images/send_btn.png"
class="emoji_action"
v-if="inputBox && !showEmojiPicker"
@click.prevent="toggleEmojiPicker"
src="@/static/images/chating_footer_emoji.png"
alt=""
srcset=""
/>
<image
class="emoji_action"
v-if="inputBox && showEmojiPicker"
@click.prevent="closeEmojiAndFocusInput"
src="@/static/images/chating_footer_audio_recording.png"
alt=""
srcset=""
/>
<image
v-show="!hasContent"
@click.prevent="updateActionBar"
src="@/static/images/chating_footer_add.png"
alt=""
srcset=""
/>
<image v-show="hasContent" @touchend.prevent="sendTextMessage" src="@/static/images/send_btn.png"
alt="" srcset=""/>
</view>
</view>
<chating-action-bar
@sendMessage="sendMessage"
@prepareMediaMessage="prepareMediaMessage"
v-show="actionBarVisible"
/>
<u-action-sheet
:safeAreaInsetBottom="true"
round="12"
:actions="actionSheetMenu"
@select="selectClick"
:closeOnClickOverlay="true"
:closeOnClickAction="true"
:show="showActionSheet"
@close="showActionSheet = false"
>
<chating-action-bar @sendMessage="sendMessage" @prepareMediaMessage="prepareMediaMessage"
v-show="actionBarVisible"/>
<u-action-sheet :safeAreaInsetBottom="true" round="12" :actions="actionSheetMenu" @select="selectClick"
:closeOnClickOverlay="true" :closeOnClickAction="true" :show="showActionSheet"
@close="showActionSheet = false">
</u-action-sheet>
<!-- Emoji 选择面板 -->
<view v-if="showEmojiPicker">
<view class="emoji-mask" @touchstart="showEmojiPicker = false" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:998;"></view>
<view class="emoji-picker" style="position:relative;z-index:999;">
<EmojiPicker @select="onEmojiSelect" />
</view>
</view>
</view>
</view>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
import { getPurePath, html2Text } from "@/util/common";
import { offlinePushInfo } from "@/util/imCommon";
import {
mapGetters,
mapActions
} from "vuex";
import {
getPurePath,
html2Text
} from "@/util/common";
import {
offlinePushInfo
} from "@/util/imCommon";
import {
ChatingFooterActionTypes,
UpdateMessageTypes,
@@ -69,15 +129,15 @@ import IMSDK, {
import UParse from "@/components/gaoyia-parse/parse.vue";
import CustomEditor from "./CustomEditor.vue";
import ChatingActionBar from "./ChatingActionBar.vue";
import EmojiPicker from '@/components/EmojiPicker/index.vue';
const needClearTypes = [MessageType.TextMessage];
const albumChoose = [
{
name: "图片",
type: ChatingFooterActionTypes.Album,
idx: 0,
},
const albumChoose = [{
name: "图片",
type: ChatingFooterActionTypes.Album,
idx: 0,
},
{
name: "拍照",
type: ChatingFooterActionTypes.Camera,
@@ -90,18 +150,28 @@ export default {
CustomEditor,
ChatingActionBar,
UParse,
EmojiPicker,
},
props: {
footerOutsideFlag: Number,
},
data() {
return {
inputBox: true,
customEditorCtx: null,
inputHtml: "",
actionBarVisible: false,
isInputFocus: false,
actionSheetMenu: [],
showActionSheet: false,
isRecording: false,
willCancel: false,
recordingTime: 0,
recordingTimer: null,
showEmojiPicker: false,
recorderManager: null,
recordFilePath: '',
recordFileDuration: 0,
};
},
computed: {
@@ -121,21 +191,77 @@ export default {
},
mounted() {
this.setKeyboardListener();
document.addEventListener('plusready', function() {
// plus 相关代码
});
},
beforeDestroy() {
this.disposeKeyboardListener();
},
methods: {
updateInput() {
this.inputBox = !this.inputBox;
this.showEmojiPicker = false;
},
startRecord(e) {
this.isRecording = true;
this.willCancel = false;
this.recordingTime = 0;
if (this.recordingTimer) clearInterval(this.recordingTimer);
this.recordingTimer = setInterval(() => {
this.recordingTime++;
}, 1000);
// 录音开始
if (!this.recorderManager) {
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStop(this.onRecordStop);
}
this.recordFilePath = '';
this.recordFileDuration = 0;
this.recorderManager.start({ format: 'mp3' });
},
stopRecord(e) {
if (this.recordingTimer) clearInterval(this.recordingTimer);
this.recordingTimer = null;
this.isRecording = false;
if (this.recorderManager) {
this.recorderManager.stop();
}
this._startY = null;
},
cancelRecord(e) {
if (this.recordingTimer) clearInterval(this.recordingTimer);
this.recordingTimer = null;
this.isRecording = false;
this.willCancel = false;
this.recordingTime = 0;
if (this.recorderManager) {
this.recorderManager.stop();
}
this._startY = null;
},
moveRecord(e) {
// 判断手指是否上滑通常通过e.touches[0].clientY与起始Y比较
if (!this.isRecording) return;
const touch = e.touches[0];
if (!this._startY && touch) {
this._startY = touch.clientY;
}
if (touch && this._startY) {
// 上滑距离大于40px则判定为取消
this.willCancel = (this._startY - touch.clientY) > 40;
}
},
...mapActions("message", ["pushNewMessage", "updateOneMessage"]),
async createTextMessage() {
let message = "";
const text = html2Text(this.inputHtml);
message = await IMSDK.asyncApi(
IMMethods.CreateTextMessage,
IMSDK.uuid(),
text
);
console.log(message);
IMMethods.CreateTextMessage,
IMSDK.uuid(),
text
);
return message;
},
async sendTextMessage() {
@@ -155,28 +281,33 @@ export default {
message,
offlinePushInfo,
})
.then(({ data }) => {
this.updateOneMessage({
message: data,
isSuccess: true,
});
})
.catch(({ data, errCode, errMsg }) => {
this.updateOneMessage({
message: data,
type: UpdateMessageTypes.KeyWords,
keyWords: [
{
.then(({
data
}) => {
this.updateOneMessage({
message: data,
isSuccess: true,
});
})
.catch(({
data,
errCode,
errMsg
}) => {
this.updateOneMessage({
message: data,
type: UpdateMessageTypes.KeyWords,
keyWords: [{
key: "status",
value: MessageStatus.Failed,
},
{
key: "errCode",
value: errCode,
},
],
{
key: "errCode",
value: errCode,
},
],
});
});
});
},
// action
@@ -187,6 +318,7 @@ export default {
},
updateActionBar() {
this.actionBarVisible = !this.actionBarVisible;
this.showEmojiPicker = false;
},
editorReady(e) {
this.customEditorCtx = e.context;
@@ -194,6 +326,7 @@ export default {
},
editorFocus() {
this.isInputFocus = true;
this.showEmojiPicker = false;
this.$emit("scrollToBottom");
},
editorBlur() {
@@ -205,29 +338,67 @@ export default {
prepareMediaMessage(type) {
if (type === ChatingFooterActionTypes.Album) {
this.actionSheetMenu = [...albumChoose];
this.showActionSheet = true;
} else if (type === ChatingFooterActionTypes.File) {
this.chooseFileAndSend();
}
this.showActionSheet = true;
},
async chooseFileAndSend() {
if (!uni.chooseMessageFile) {
return;
}
uni.chooseMessageFile({
count: 1,
type: 'file',
success: async (res) => {
const file = res.tempFiles[0];
try {
const nameIdx = file.name.lastIndexOf("/") + 1;
const fileName = file.name.slice(nameIdx);
const typeIdx = file.name.lastIndexOf(".") + 1;
const fileType = file.name.slice(typeIdx);
const { data: { url } } = await IMSDK.asyncApi(IMMethods.UploadFile, IMSDK.uuid(), {
filepath: file.path,
name: fileName,
contentType: fileType,
uuid: IMSDK.uuid(),
});
// 创建文件消息
const message = await IMSDK.asyncApi(IMMethods.CreateFileMessage, IMSDK.uuid(), {
fileName: fileName,
fileSize: file.size,
sourceUrl: url,
});
this.sendMessage(message);
} catch (e) {
}
},
fail: function (err) {
},
});
},
// from comp
batchCreateImageMesage(paths) {
paths.forEach(async (path) => {
const message = await IMSDK.asyncApi(
IMMethods.CreateImageMessageFromFullPath,
IMSDK.uuid(),
getPurePath(path)
IMMethods.CreateImageMessageFromFullPath,
IMSDK.uuid(),
getPurePath(path)
);
this.sendMessage(message);
});
},
selectClick({ idx }) {
selectClick({
idx
}) {
if (idx === 0) {
this.chooseOrShotImage(["album"]).then((paths) =>
this.batchCreateImageMesage(paths)
this.batchCreateImageMesage(paths)
);
} else {
this.chooseOrShotImage(["camera"]).then((paths) =>
this.batchCreateImageMesage(paths)
this.batchCreateImageMesage(paths)
);
}
},
@@ -237,11 +408,12 @@ export default {
count: 9,
sizeType: ["compressed"],
sourceType,
success: function ({ tempFilePaths }) {
success: function ({
tempFilePaths
}) {
resolve(tempFilePaths);
},
fail: function (err) {
console.log(err);
reject(err);
},
});
@@ -249,7 +421,9 @@ export default {
},
// keyboard
keyboardChangeHander({ height }) {
keyboardChangeHander({
height
}) {
if (height > 0) {
if (this.actionBarVisible) {
this.actionBarVisible = false;
@@ -262,6 +436,116 @@ export default {
disposeKeyboardListener() {
uni.offKeyboardHeightChange(this.keyboardChangeHander);
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
if (!this.showEmojiPicker && this.$refs.customEditor && this.$refs.customEditor.editorCtx) {
// 关闭emoji面板时再focus
this.$refs.customEditor.editorCtx.focus();
}
},
closeEmojiAndFocusInput() {
this.showEmojiPicker = false;
// 只在这里主动focus
if (this.$refs.customEditor && this.$refs.customEditor.editorCtx) {
this.$refs.customEditor.editorCtx.focus();
}
},
onEmojiSelect(emoji) {
this.inputHtml += emoji;
if (this.$refs.customEditor && this.$refs.customEditor.editorCtx) {
this.$refs.customEditor.editorCtx.setContents({
html: this.inputHtml
});
}
// 不做任何focus/blur
},
async onRecordStop(res) {
let tempFilePath = res.tempFilePath;
let fileName = tempFilePath.split('/').pop();
let fileType = fileName.split('.').pop();
let uuid = IMSDK.uuid();
let duration = this.recordingTime;
if (!duration || isNaN(duration) || duration < 1) {
this.recordingTime = 0;
return;
}
// 先保存到本地
uni.saveFile({
tempFilePath,
success: (saveRes) => {
let savedFilePath = saveRes.savedFilePath;
// 用 plus.io 转换为原生绝对路径
if (typeof plus !== 'undefined') {
plus.io.resolveLocalFileSystemURL(savedFilePath, (entry) => {
IMSDK.asyncApi('uploadFile', uuid, {
name: fileName,
contentType: fileType,
uuid: uuid,
filepath: entry.fullPath
})
.then(({ data }) => {
let sourceUrl = data.url;
IMSDK.asyncApi('createSoundMessageByURL', IMSDK.uuid(), {
uuid: uuid,
soundPath: entry.fullPath,
sourceUrl: sourceUrl,
dataSize: res.fileSize || 0,
duration: duration,
soundType: fileType
})
.then((message) => {
IMSDK.asyncApi('sendMessageNotOss', IMSDK.uuid(), {
recvID: this.storeCurrentConversation.userID,
groupID: this.storeCurrentConversation.groupID,
message: message,
offlinePushInfo: {
title: '你有新消息',
desc: '新消息',
ex: '',
iOSPushSound: '+1',
iOSBadgeCount: true,
}
})
.then(({ data }) => {
// 1. 本地新增消息
this.pushNewMessage(message);
// 2. 更新消息状态
this.updateOneMessage({
message: data,
isSuccess: true,
});
this.$emit("scrollToBottom");
})
.catch(({ errCode, errMsg }) => {
// 发送失败时也要更新消息状态为失败
this.updateOneMessage({
message: message,
type: UpdateMessageTypes.KeyWords,
keyWords: [
{ key: "status", value: MessageStatus.Failed },
{ key: "errCode", value: errCode },
],
});
});
})
.catch(() => {
// 忽略错误提示
});
})
.catch(() => {
// 忽略错误提示
});
}, () => {
// 忽略路径转换失败
});
}
},
fail: () => {
// 忽略保存失败
}
});
this.recordingTime = 0;
},
},
};
</script>
@@ -298,7 +582,7 @@ export default {
min-height: 30px;
max-height: 120px;
margin: 0 24rpx;
border-radius: 8rpx;
border-radius: 16rpx;
position: relative;
.record_btn {
@@ -350,4 +634,18 @@ export default {
color: #fff;
}
}
</style>
.emoji-mask {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: transparent;
z-index: 998;
}
.emoji-picker {
position: relative;
z-index: 999;
}
</style>

View File

@@ -140,10 +140,7 @@ export default {
return;
}
if (!isInit) {
this.withAnimation = true;
setTimeout(() => (this.withAnimation = false), 100);
}
this.withAnimation = true;
this.$nextTick(() => {
uni
@@ -151,14 +148,11 @@ export default {
.in(this)
.select("#scroll_wrap")
.boundingClientRect((res) => {
// let top = res.height - this.scrollViewHeight;
// if (top > 0) {
this.scrollTop = this.old.scrollTop
this.$nextTick(() => this.scrollTop = res.height);
if (isInit) {
this.$emit("initSuccess");
}
// }
})
.exec();
});

View File

@@ -20,7 +20,8 @@ export default {
},
computed: {
getContent() {
return parseBr(this.message.textElem?.content);
console.log(this.message);
return parseBr(this.message.textElem.content);
},
},
data() {

View File

@@ -0,0 +1,97 @@
<template>
<view class="voice-message" @click="playVoice">
<image
:src="isPlaying ? audioIcon : recordIcon"
class="audio-icon flipped"
mode="aspectFit"
/>
<text class="duration">{{ durationText }}</text>
</view>
</template>
<script>
export default {
props: {
source: {
type: Object,
required: true
}
},
computed: {
durationText() {
const d = this.source.soundElem && this.source.soundElem.duration;
return d ? `${d}''` : '';
}
},
data() {
return {
innerAudioContext: null,
isPlaying: false,
audioIcon: '/static/images/chating_footer_audio.png',
recordIcon: '/static/images/chating_footer_recording.png'
};
},
methods: {
getFixedSourceUrl(url) {
// 如果 url 以 http://47.117.71.33/api/object/ 开头,则替换为带端口的
if (typeof url === 'string' && url.startsWith('http://47.117.71.33/api/object/')) {
return url.replace('http://47.117.71.33/api/object/', 'http://47.117.71.33:15219/api/object/');
}
return url;
},
playVoice() {
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
this.innerAudioContext = uni.createInnerAudioContext();
const rawUrl = this.source.soundElem && this.source.soundElem.sourceUrl || '';
this.innerAudioContext.src = this.getFixedSourceUrl(rawUrl);
this.isPlaying = true;
this.innerAudioContext.play();
this.innerAudioContext.onEnded(() => {
this.innerAudioContext.destroy();
this.innerAudioContext = null;
this.isPlaying = false;
});
this.innerAudioContext.onStop(() => {
this.isPlaying = false;
});
this.innerAudioContext.onError(() => {
this.isPlaying = false;
});
}
},
beforeDestroy() {
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
}
};
</script>
<style scoped>
.voice-message {
display: flex;
align-items: center;
cursor: pointer;
background: #f5f6fa;
border-radius: 18px;
padding: 8px 16px;
margin: 4px 0;
}
.audio-icon {
width: 28px;
height: 28px;
}
.flipped {
transform: rotate(180deg);
}
.duration {
margin-left: 8px;
color: #666;
font-size: 15px;
}
</style>

View File

@@ -37,12 +37,36 @@
</view>
</view>
<view class="message_content_wrap message_content_wrap_shadow">
<text-message-render
v-if="showTextRender"
:message="source"
/>
<media-message-render v-else-if="showMediaRender" :message="source" />
<error-message-render v-else />
<template v-if="source.contentType === 101">
<TextMessageRender :message="source" />
</template>
<template v-else-if="source.contentType === 102">
<MediaMessageRender :source="source" />
</template>
<template v-else-if="source.contentType === 103">
<VoiceMessageRender :source="source" />
</template>
<template v-else-if="source.contentType === 104">
<view style="color:#999">[暂未实现] 视频消息</view>
</template>
<template v-else-if="source.contentType === 105">
<view style="color:#999">[暂未实现] 文件消息</view>
</template>
<template v-else-if="source.contentType === 106">
<view style="color:#999">[暂未实现] @消息</view>
</template>
<template v-else-if="source.contentType === 109">
<view style="color:#999">[暂未实现] 位置消息</view>
</template>
<template v-else-if="source.contentType === 110">
<view style="color:#999">[暂未实现] 自定义消息</view>
</template>
<template v-else-if="source.contentType === 1400">
<view style="color:#999">[暂未实现] 系统通知</view>
</template>
<template v-else>
<ErrorMessageRender :source="source" />
</template>
</view>
</view>
</view>
@@ -68,6 +92,7 @@ import MyAvatar from "@/components/MyAvatar/index.vue";
import TextMessageRender from "./TextMessageRender.vue";
import MediaMessageRender from "./MediaMessageRender.vue";
import ErrorMessageRender from "./ErrorMessageRender.vue";
import VoiceMessageRender from './VoiceMessageRender.vue'
import { noticeMessageTypes } from "@/constant";
import { tipMessaggeFormat, formatMessageTime } from "@/util/imCommon";
@@ -81,6 +106,7 @@ export default {
TextMessageRender,
MediaMessageRender,
ErrorMessageRender,
VoiceMessageRender
},
props: {
source: Object,

View File

@@ -48,10 +48,13 @@ export default {
},
computed: {
latestMessage() {
if (this.source.latestMsg === "") return "";
let parsedMessage;
try {
parsedMessage = JSON.parse(this.source.latestMsg);
console.log(parsedMessage);
} catch (e) {}
if (!parsedMessage) return "";
return getConversationContent(parsedMessage);

View File

@@ -128,6 +128,8 @@ export const parseMessageByType = (pmsg) => {
return pmsg.textElem.content;
case MessageType.PictureMessage:
return `[图片]`;
case MessageType.VoiceMessage:
return `[语音]`;
case MessageType.FriendAdded:
return "你们已经是好友了,开始聊天吧~";
case MessageType.MemberEnter: