|
|
@@ -117,9 +117,23 @@
|
|
|
@click.stop="previewImage(msg.mediaUrl)" />
|
|
|
|
|
|
<!-- 语音消息 -->
|
|
|
- <view v-else-if="msg.messageType === 3" class="message-voice" @click.stop="playVoice(msg)">
|
|
|
- <text>🔊</text>
|
|
|
- <text>{{ msg.duration }}"</text>
|
|
|
+ <view
|
|
|
+ v-else-if="msg.messageType === 3"
|
|
|
+ class="message-voice"
|
|
|
+ :style="{width: getVoiceWidth(msg.duration)}"
|
|
|
+ @click.stop="toggleVoicePlay(msg)">
|
|
|
+ <text class="voice-duration">{{ msg.duration }}''</text>
|
|
|
+ <view class="voice-icon-wrapper" :class="{playing: playingVoiceId === msg.messageId}">
|
|
|
+ <image class="voice-icon" src="/static/voice-icon.png" mode="aspectFit"></image>
|
|
|
+ </view>
|
|
|
+ <!-- 暂停后的继续播放按钮 -->
|
|
|
+ <view
|
|
|
+ v-if="pausedVoiceId === msg.messageId"
|
|
|
+ class="voice-resume-btn"
|
|
|
+ :class="{'resume-btn-left': msg.fromUserId === userId, 'resume-btn-right': msg.fromUserId !== userId}"
|
|
|
+ @click.stop="resumeVoice">
|
|
|
+ <text class="resume-icon">▶</text>
|
|
|
+ </view>
|
|
|
</view>
|
|
|
|
|
|
<!-- 视频消息 -->
|
|
|
@@ -180,18 +194,20 @@
|
|
|
v-else
|
|
|
class="voice-button"
|
|
|
@touchstart="startVoiceRecord"
|
|
|
- @touchend="stopVoiceRecord">
|
|
|
+ @touchmove="onVoiceTouchMove"
|
|
|
+ @touchend="stopVoiceRecord"
|
|
|
+ @touchcancel="cancelVoiceRecord">
|
|
|
按住说话
|
|
|
</button>
|
|
|
|
|
|
<!-- 表情按钮 -->
|
|
|
- <view class="input-icon" @click="showEmojiPanel = !showEmojiPanel">
|
|
|
+ <view v-if="inputType === 'text'" class="input-icon" @click="showEmojiPanel = !showEmojiPanel">
|
|
|
<text>😊</text>
|
|
|
</view>
|
|
|
|
|
|
- <!-- 更多按钮 -->
|
|
|
- <view class="input-icon" @click="showMorePanel = !showMorePanel">
|
|
|
- <text>➕</text>
|
|
|
+ <!-- 发送按钮 -->
|
|
|
+ <view v-if="inputType === 'text'" class="send-button" :class="{disabled: !inputText.trim()}" @click="sendTextMessage">
|
|
|
+ <text>发送</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
|
|
|
@@ -205,22 +221,6 @@
|
|
|
{{ emoji }}
|
|
|
</text>
|
|
|
</view>
|
|
|
-
|
|
|
- <!-- 更多功能面板 -->
|
|
|
- <view v-if="showMorePanel" class="more-panel">
|
|
|
- <view class="more-item" @click="chooseImage">
|
|
|
- <view class="more-icon">🖼️</view>
|
|
|
- <text class="more-text">图片</text>
|
|
|
- </view>
|
|
|
- <view class="more-item" @click="chooseVideo">
|
|
|
- <view class="more-icon">📹</view>
|
|
|
- <text class="more-text">视频</text>
|
|
|
- </view>
|
|
|
- <view class="more-item" @click="chooseFile">
|
|
|
- <view class="more-icon">📁</view>
|
|
|
- <text class="more-text">文件</text>
|
|
|
- </view>
|
|
|
- </view>
|
|
|
|
|
|
<!-- 消息操作菜单 -->
|
|
|
<view v-if="showMessageAction" class="message-action-mask" @click="hideMessageMenu">
|
|
|
@@ -242,6 +242,22 @@
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
+
|
|
|
+ <!-- 录音中提示 -->
|
|
|
+ <view v-if="showVoiceRecording" class="voice-recording-mask">
|
|
|
+ <view class="voice-recording-box" :class="{'canceling': voiceCanceling}">
|
|
|
+ <view class="voice-wave-container">
|
|
|
+ <view class="voice-wave-bar bar1" :style="{transform: `scaleY(${voiceVolume})`}"></view>
|
|
|
+ <view class="voice-wave-bar bar2" :style="{transform: `scaleY(${voiceVolume * 1.2})`}"></view>
|
|
|
+ <view class="voice-wave-bar bar3" :style="{transform: `scaleY(${voiceVolume * 1.5})`}"></view>
|
|
|
+ <view class="voice-wave-bar bar4" :style="{transform: `scaleY(${voiceVolume * 1.3})`}"></view>
|
|
|
+ <view class="voice-wave-bar bar5" :style="{transform: `scaleY(${voiceVolume})`}"></view>
|
|
|
+ </view>
|
|
|
+ <text class="voice-text">{{ voiceCanceling ? '松开取消发送' : '正在录音...' }}</text>
|
|
|
+ <text class="voice-time">{{ voiceRecordingTime }}''</text>
|
|
|
+ <text class="voice-tip" v-if="!voiceCanceling">松开发送,上滑取消</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
</view>
|
|
|
</template>
|
|
|
|
|
|
@@ -266,7 +282,6 @@ export default {
|
|
|
|
|
|
scrollToView: '',
|
|
|
showEmojiPanel: false,
|
|
|
- showMorePanel: false,
|
|
|
|
|
|
isLogin: false,
|
|
|
|
|
|
@@ -287,7 +302,23 @@ export default {
|
|
|
showBlockConfirmModal: false, // 控制拉黑确认弹窗显示
|
|
|
isVip: false, // 是否VIP用户
|
|
|
remainingCount: 5, // 剩余可发送消息数(非VIP默认5)
|
|
|
- hasMessageLimit: true // 是否有发送限制(VIP为false)
|
|
|
+ hasMessageLimit: true, // 是否有发送限制(VIP为false)
|
|
|
+
|
|
|
+ // 语音录制相关
|
|
|
+ recorderManager: null, // 录音管理器
|
|
|
+ isRecording: false, // 是否正在录音
|
|
|
+ voiceStartTime: 0, // 录音开始时间
|
|
|
+ voiceDuration: 0, // 录音时长
|
|
|
+ voiceTempPath: '', // 录音临时文件路径
|
|
|
+ showVoiceRecording: false, // 显示录音中提示
|
|
|
+ voiceRecordingTime: 0, // 录音计时(秒)
|
|
|
+ voiceRecordingTimer: null, // 录音计时器
|
|
|
+ voiceTouchStartY: 0, // 录音按下时的Y坐标
|
|
|
+ voiceCanceling: false, // 是否正在取消录音
|
|
|
+ voiceVolume: 0.3, // 录音音量(0-1),控制波形高度
|
|
|
+ playingVoiceId: null, // 当前播放的语音消息ID
|
|
|
+ pausedVoiceId: null, // 当前暂停的语音消息ID
|
|
|
+ currentAudioContext: null // 当前音频上下文
|
|
|
};
|
|
|
},
|
|
|
|
|
|
@@ -388,9 +419,31 @@ export default {
|
|
|
onUnload() {
|
|
|
// 页面卸载时移除监听
|
|
|
timManager.offMessage(this.handleNewMessage);
|
|
|
+
|
|
|
+ // 移除消息状态变更监听(已注释)
|
|
|
+ /*
|
|
|
+ if (timManager.tim && this.handleStatusChange) {
|
|
|
+ timManager.tim.off(TIM.EVENT.MESSAGE_STATUS_CHANGED, this.handleStatusChange);
|
|
|
+ }
|
|
|
+ */
|
|
|
},
|
|
|
|
|
|
methods: {
|
|
|
+ /**
|
|
|
+ * 计算语音消息宽度(根据时长动态变化)
|
|
|
+ * @param {Number} duration 语音时长(秒)
|
|
|
+ * @return {String} 宽度值
|
|
|
+ */
|
|
|
+ getVoiceWidth(duration) {
|
|
|
+ // 基础宽度 100rpx,每秒增加 10rpx,最大 400rpx
|
|
|
+ const baseWidth = 100;
|
|
|
+ const widthPerSecond = 10;
|
|
|
+ const maxWidth = 400;
|
|
|
+
|
|
|
+ const width = Math.min(baseWidth + (duration * widthPerSecond), maxWidth);
|
|
|
+ return width + 'rpx';
|
|
|
+ },
|
|
|
+
|
|
|
/**
|
|
|
* 初始化 TIM
|
|
|
*/
|
|
|
@@ -669,9 +722,29 @@ export default {
|
|
|
timManager.onMessage(handleNewMessage);
|
|
|
|
|
|
// 添加消息状态变更监听
|
|
|
+ // 注释原因:TIM.EVENT.MESSAGE_STATUS_CHANGED 在当前SDK版本中可能不存在,导致参数验证失败
|
|
|
+ // 消息状态更新已通过 handleNewMessage 处理,暂时不需要单独监听
|
|
|
+ /*
|
|
|
if (timManager.tim) {
|
|
|
- timManager.tim.on(TIM.EVENT.MESSAGE_STATUS_CHANGED, handleNewMessage);
|
|
|
+ const handleStatusChange = (event) => {
|
|
|
+ console.log('📝 消息状态变更:', event);
|
|
|
+ // MESSAGE_STATUS_CHANGED 事件的数据结构不同
|
|
|
+ if (event && event.data && Array.isArray(event.data)) {
|
|
|
+ event.data.forEach(msg => {
|
|
|
+ if (msg.conversationID === this.conversationID) {
|
|
|
+ const existingIndex = this.messages.findIndex(m => m.messageId === msg.ID);
|
|
|
+ if (existingIndex > -1) {
|
|
|
+ this.$set(this.messages, existingIndex, this.convertMessage(msg));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ this.handleStatusChange = handleStatusChange;
|
|
|
+ timManager.tim.on(TIM.EVENT.MESSAGE_STATUS_CHANGED, handleStatusChange);
|
|
|
}
|
|
|
+ */
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
@@ -695,16 +768,53 @@ export default {
|
|
|
sendStatus = 1; // 发送中
|
|
|
}
|
|
|
|
|
|
+ // 处理不同类型消息的URL和时长
|
|
|
+ let mediaUrl = '';
|
|
|
+ let duration = 0;
|
|
|
+ let messageType = 1;
|
|
|
+ let content = '';
|
|
|
+
|
|
|
+ if (timMsg.type === TIM.TYPES.MSG_TEXT) {
|
|
|
+ messageType = 1;
|
|
|
+ content = timMsg.payload.text;
|
|
|
+ } else if (timMsg.type === TIM.TYPES.MSG_IMAGE && timMsg.payload.imageInfoArray) {
|
|
|
+ messageType = 2;
|
|
|
+ content = '[图片]';
|
|
|
+ mediaUrl = timMsg.payload.imageInfoArray[0]?.url || '';
|
|
|
+ } else if (timMsg.type === TIM.TYPES.MSG_SOUND) {
|
|
|
+ // 原生语音消息
|
|
|
+ messageType = 3;
|
|
|
+ content = '[语音]';
|
|
|
+ mediaUrl = timMsg.payload.url || timMsg.payload.remoteAudioUrl || '';
|
|
|
+ duration = timMsg.payload.second || 0;
|
|
|
+ } else if (timMsg.type === TIM.TYPES.MSG_CUSTOM) {
|
|
|
+ // 自定义消息(我们的语音消息)
|
|
|
+ try {
|
|
|
+ const customData = JSON.parse(timMsg.payload.data);
|
|
|
+ if (customData.type === 'voice') {
|
|
|
+ messageType = 3;
|
|
|
+ content = '[语音]';
|
|
|
+ mediaUrl = customData.url;
|
|
|
+ // duration已经是秒数,直接使用
|
|
|
+ duration = parseInt(customData.duration) || 0;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('解析自定义消息失败:', e);
|
|
|
+ }
|
|
|
+ } else if (timMsg.type === TIM.TYPES.MSG_VIDEO) {
|
|
|
+ messageType = 4;
|
|
|
+ content = '[视频]';
|
|
|
+ }
|
|
|
+
|
|
|
return {
|
|
|
messageId: timMsg.ID,
|
|
|
fromUserId: timMsg.from,
|
|
|
toUserId: timMsg.to,
|
|
|
- messageType: timMsg.type === TIM.TYPES.MSG_TEXT ? 1 :
|
|
|
- timMsg.type === TIM.TYPES.MSG_IMAGE ? 2 :
|
|
|
- timMsg.type === TIM.TYPES.MSG_SOUND ? 3 :
|
|
|
- timMsg.type === TIM.TYPES.MSG_VIDEO ? 4 : 1,
|
|
|
- content: timMsg.type === TIM.TYPES.MSG_TEXT ? timMsg.payload.text : '[图片]',
|
|
|
- mediaUrl: timMsg.type === TIM.TYPES.MSG_IMAGE ? timMsg.payload.imageInfoArray?.[0]?.url : '',
|
|
|
+ messageType: messageType,
|
|
|
+ content: content || '[消息]',
|
|
|
+ mediaUrl: mediaUrl,
|
|
|
+ duration: duration,
|
|
|
+ payload: timMsg.payload, // 保留原始payload
|
|
|
sendStatus: sendStatus,
|
|
|
sendTime: new Date(timMsg.time * 1000),
|
|
|
isRecalled: timMsg.isRevoked,
|
|
|
@@ -898,8 +1008,6 @@ export default {
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
-
|
|
|
- this.showMorePanel = false;
|
|
|
},
|
|
|
|
|
|
/**
|
|
|
@@ -934,6 +1042,27 @@ export default {
|
|
|
syncData.thumbnailUrl = imageInfo.imageUrl;
|
|
|
}
|
|
|
|
|
|
+ // 如果是语音消息,添加语音信息
|
|
|
+ if (timMessage.type === 'TIMSoundElem' && timMessage.payload) {
|
|
|
+ syncData.mediaUrl = timMessage.payload.url || timMessage.payload.remoteAudioUrl;
|
|
|
+ syncData.duration = timMessage.payload.second || 0;
|
|
|
+ syncData.mediaSize = timMessage.payload.size || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果是自定义消息(我们的语音消息)
|
|
|
+ if (timMessage.type === 'TIMCustomElem' && timMessage.payload) {
|
|
|
+ try {
|
|
|
+ const customData = JSON.parse(timMessage.payload.data);
|
|
|
+ if (customData.type === 'voice') {
|
|
|
+ syncData.mediaUrl = customData.url;
|
|
|
+ syncData.duration = customData.duration;
|
|
|
+ syncData.mediaSize = customData.size || 0;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('解析自定义消息失败:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// 调用后端同步接口
|
|
|
const res = await uni.request({
|
|
|
url: 'http://localhost:1004/api/chat/syncTIMMessage',
|
|
|
@@ -966,6 +1095,19 @@ export default {
|
|
|
'TIMVideoFileElem': 4, // 视频
|
|
|
'TIMFileElem': 5 // 文件
|
|
|
};
|
|
|
+
|
|
|
+ // 处理自定义消息(我们的语音消息)
|
|
|
+ if (timMessage.type === 'TIMCustomElem' && timMessage.payload) {
|
|
|
+ try {
|
|
|
+ const customData = JSON.parse(timMessage.payload.data);
|
|
|
+ if (customData.type === 'voice') {
|
|
|
+ return 3; // 语音消息
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('解析自定义消息类型失败:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
return typeMap[timMessage.type] || 1;
|
|
|
},
|
|
|
|
|
|
@@ -1287,18 +1429,345 @@ export default {
|
|
|
switchInputType() {
|
|
|
this.inputType = this.inputType === 'text' ? 'voice' : 'text';
|
|
|
},
|
|
|
- startVoiceRecord() {
|
|
|
- uni.showToast({
|
|
|
- title: '按住说话',
|
|
|
- icon: 'none'
|
|
|
- });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 开始录音
|
|
|
+ */
|
|
|
+ startVoiceRecord(e) {
|
|
|
+ console.log('🎤 [按住说话] startVoiceRecord 方法被调用');
|
|
|
+
|
|
|
+ // 记录触摸起始位置
|
|
|
+ this.voiceTouchStartY = e.touches[0].clientY;
|
|
|
+ this.voiceCanceling = false;
|
|
|
+
|
|
|
+ // 检查消息发送限制
|
|
|
+ if (this.hasMessageLimit && this.remainingCount <= 0) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '今日消息发送次数已用完',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始化录音管理器
|
|
|
+ if (!this.recorderManager) {
|
|
|
+ console.log('📱 初始化录音管理器');
|
|
|
+ this.recorderManager = uni.getRecorderManager();
|
|
|
+
|
|
|
+ // 录音开始
|
|
|
+ this.recorderManager.onStart(() => {
|
|
|
+ console.log('✅ 录音开始回调触发');
|
|
|
+ this.isRecording = true;
|
|
|
+ this.showVoiceRecording = true;
|
|
|
+ this.voiceStartTime = Date.now();
|
|
|
+ console.log(' - isRecording:', this.isRecording);
|
|
|
+ console.log(' - showVoiceRecording:', this.showVoiceRecording);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 录音结束
|
|
|
+ this.recorderManager.onStop((res) => {
|
|
|
+ console.log('🎤 录音结束:', res, ', voiceCanceling:', this.voiceCanceling);
|
|
|
+ this.isRecording = false;
|
|
|
+ this.showVoiceRecording = false;
|
|
|
+
|
|
|
+ // 如果是取消状态,不发送
|
|
|
+ if (this.voiceCanceling) {
|
|
|
+ console.log('❌ 录音已取消,不发送');
|
|
|
+ this.voiceCanceling = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.voiceTempPath = res.tempFilePath;
|
|
|
+ this.voiceDuration = Math.floor(res.duration / 1000); // 转换为秒
|
|
|
+
|
|
|
+ console.log(' - 文件路径:', this.voiceTempPath);
|
|
|
+ console.log(' - 时长:', this.voiceDuration, '秒');
|
|
|
+
|
|
|
+ // 时长验证
|
|
|
+ if (this.voiceDuration < 1) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '录音时间太短',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.voiceDuration > 60) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '录音时间不能超过60秒',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 发送语音消息
|
|
|
+ this.sendVoiceMessage();
|
|
|
+ });
|
|
|
+
|
|
|
+ // 录音错误
|
|
|
+ this.recorderManager.onError((err) => {
|
|
|
+ console.error('❌ 录音错误:', err);
|
|
|
+ this.isRecording = false;
|
|
|
+ this.showVoiceRecording = false;
|
|
|
+ uni.showToast({
|
|
|
+ title: '录音失败',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 立即设置录音状态(不等待onStart回调)
|
|
|
+ this.isRecording = true;
|
|
|
+ this.showVoiceRecording = true;
|
|
|
+ this.voiceStartTime = Date.now();
|
|
|
+ this.voiceRecordingTime = 0;
|
|
|
+ this.voiceVolume = 0.3;
|
|
|
+ console.log('🎬 立即设置录音状态');
|
|
|
+ console.log(' - isRecording:', this.isRecording);
|
|
|
+ console.log(' - showVoiceRecording:', this.showVoiceRecording);
|
|
|
+
|
|
|
+ // 启动计时器
|
|
|
+ let volumeDirection = 1;
|
|
|
+ let currentVolume = 0.3;
|
|
|
+ this.voiceRecordingTimer = setInterval(() => {
|
|
|
+ this.voiceRecordingTime = Math.floor((Date.now() - this.voiceStartTime) / 1000);
|
|
|
+
|
|
|
+ // 模拟更真实的音量波动
|
|
|
+ // 随机决定是否有声音(80%概率有声音)
|
|
|
+ if (Math.random() > 0.2) {
|
|
|
+ // 有声音:平滑波动
|
|
|
+ currentVolume += volumeDirection * (0.1 + Math.random() * 0.2);
|
|
|
+ if (currentVolume > 1.0) {
|
|
|
+ currentVolume = 1.0;
|
|
|
+ volumeDirection = -1;
|
|
|
+ } else if (currentVolume < 0.4) {
|
|
|
+ currentVolume = 0.4;
|
|
|
+ volumeDirection = 1;
|
|
|
+ }
|
|
|
+ this.voiceVolume = currentVolume;
|
|
|
+ } else {
|
|
|
+ // 无声音:降低到最小
|
|
|
+ this.voiceVolume = Math.max(0.2, this.voiceVolume * 0.8);
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+
|
|
|
+ // 开始录音
|
|
|
+ console.log('🎙️ 调用 recorderManager.start()');
|
|
|
+ try {
|
|
|
+ this.recorderManager.start({
|
|
|
+ format: 'mp3',
|
|
|
+ sampleRate: 16000,
|
|
|
+ numberOfChannels: 1,
|
|
|
+ encodeBitRate: 48000
|
|
|
+ });
|
|
|
+ console.log('✅ recorderManager.start() 调用成功');
|
|
|
+ } catch (err) {
|
|
|
+ console.error('❌ recorderManager.start() 调用失败:', err);
|
|
|
+ this.isRecording = false;
|
|
|
+ this.showVoiceRecording = false;
|
|
|
+ }
|
|
|
},
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止录音
|
|
|
+ */
|
|
|
stopVoiceRecord() {
|
|
|
+ console.log('🎤 [松开] stopVoiceRecord 方法被调用, isRecording:', this.isRecording, ', voiceCanceling:', this.voiceCanceling);
|
|
|
+
|
|
|
+ // 清除计时器
|
|
|
+ if (this.voiceRecordingTimer) {
|
|
|
+ clearInterval(this.voiceRecordingTimer);
|
|
|
+ this.voiceRecordingTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果正在取消,则取消录音
|
|
|
+ if (this.voiceCanceling) {
|
|
|
+ console.log('🚫 检测到取消状态,调用 cancelVoiceRecord');
|
|
|
+ this.cancelVoiceRecord();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.isRecording && this.recorderManager) {
|
|
|
+ const duration = Date.now() - this.voiceStartTime;
|
|
|
+
|
|
|
+ // 录音时间太短
|
|
|
+ if (duration < 1000) {
|
|
|
+ this.recorderManager.stop();
|
|
|
+ this.isRecording = false;
|
|
|
+ this.showVoiceRecording = false;
|
|
|
+ uni.showToast({
|
|
|
+ title: '录音时间太短',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.recorderManager.stop();
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 触摸移动 - 检测上滑取消
|
|
|
+ */
|
|
|
+ onVoiceTouchMove(e) {
|
|
|
+ if (!this.isRecording) return;
|
|
|
+
|
|
|
+ const currentY = e.touches[0].clientY;
|
|
|
+ const deltaY = this.voiceTouchStartY - currentY;
|
|
|
+
|
|
|
+ // 上滑超过100px,显示取消提示
|
|
|
+ if (deltaY > 100) {
|
|
|
+ this.voiceCanceling = true;
|
|
|
+ console.log('⬆️ 上滑取消录音');
|
|
|
+ } else {
|
|
|
+ this.voiceCanceling = false;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取消录音
|
|
|
+ */
|
|
|
+ cancelVoiceRecord() {
|
|
|
+ console.log('❌ 取消录音, voiceCanceling:', this.voiceCanceling);
|
|
|
+
|
|
|
+ // 清除计时器
|
|
|
+ if (this.voiceRecordingTimer) {
|
|
|
+ clearInterval(this.voiceRecordingTimer);
|
|
|
+ this.voiceRecordingTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 先设置取消状态,再停止录音
|
|
|
+ // 这样onStop回调中能正确判断
|
|
|
+ if (!this.voiceCanceling) {
|
|
|
+ this.voiceCanceling = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.recorderManager && this.isRecording) {
|
|
|
+ this.recorderManager.stop(); // 这会触发onStop回调
|
|
|
+ } else {
|
|
|
+ // 如果没有在录音,直接重置状态
|
|
|
+ this.isRecording = false;
|
|
|
+ this.showVoiceRecording = false;
|
|
|
+ this.voiceCanceling = false;
|
|
|
+ }
|
|
|
+
|
|
|
uni.showToast({
|
|
|
- title: '语音功能开发中',
|
|
|
+ title: '已取消录音',
|
|
|
icon: 'none'
|
|
|
});
|
|
|
},
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发送语音消息
|
|
|
+ */
|
|
|
+ async sendVoiceMessage() {
|
|
|
+ // 检查是否被拉黑
|
|
|
+ const isBlocked = await this.checkIsBlockedByTarget();
|
|
|
+ if (isBlocked) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '你已被对方拉黑,无法发送消息',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ uni.showLoading({ title: '上传中...' });
|
|
|
+
|
|
|
+ // 第一步:上传语音文件到MinIO
|
|
|
+ const uploadResult = await this.uploadVoiceToMinIO();
|
|
|
+ if (!uploadResult.success) {
|
|
|
+ throw new Error(uploadResult.message || '上传失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log('✅ 语音文件上传成功:', uploadResult.fileUrl);
|
|
|
+ uni.showLoading({ title: '发送中...' });
|
|
|
+
|
|
|
+ // 第二步:通过腾讯IM发送语音消息(携带MinIO URL)
|
|
|
+ const message = await timManager.sendVoiceMessage(
|
|
|
+ this.targetUserId,
|
|
|
+ uploadResult.fileUrl,
|
|
|
+ this.voiceDuration,
|
|
|
+ uploadResult.fileSize
|
|
|
+ );
|
|
|
+
|
|
|
+ // 添加到消息列表
|
|
|
+ this.messages.push(this.convertMessage(message));
|
|
|
+ this.scrollToBottom();
|
|
|
+
|
|
|
+ uni.hideLoading();
|
|
|
+ console.log('✅ 语音消息发送成功');
|
|
|
+
|
|
|
+ // 同步到MySQL
|
|
|
+ this.syncMessageToMySQL(message);
|
|
|
+
|
|
|
+ // 更新消息计数
|
|
|
+ if (!this.isVip) {
|
|
|
+ await this.updateMessageCount();
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ uni.hideLoading();
|
|
|
+ console.error('❌ 语音消息发送失败:', error);
|
|
|
+ uni.showToast({
|
|
|
+ title: '发送失败: ' + error.message,
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传语音文件到MinIO
|
|
|
+ */
|
|
|
+ async uploadVoiceToMinIO() {
|
|
|
+ try {
|
|
|
+ console.log('📤 开始上传语音文件到MinIO...');
|
|
|
+ console.log(' - 文件路径:', this.voiceTempPath);
|
|
|
+ console.log(' - 时长:', this.voiceDuration, '秒');
|
|
|
+
|
|
|
+ // 使用uni.uploadFile上传到后端MinIO接口
|
|
|
+ const [err, res] = await uni.uploadFile({
|
|
|
+ url: 'http://localhost:1004/api/voice/upload',
|
|
|
+ filePath: this.voiceTempPath,
|
|
|
+ name: 'file',
|
|
|
+ header: {
|
|
|
+ 'Content-Type': 'multipart/form-data'
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (err) {
|
|
|
+ console.error('❌ 上传请求失败:', err);
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ message: '网络请求失败'
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 解析响应
|
|
|
+ const result = JSON.parse(res.data);
|
|
|
+ console.log('📥 MinIO上传响应:', result);
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ fileUrl: result.fileUrl,
|
|
|
+ fileSize: result.fileSize
|
|
|
+ };
|
|
|
+ } else {
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ message: result.message || '上传失败'
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('❌ 上传语音文件失败:', error);
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ message: error.message || '上传异常'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
insertEmoji(emoji) {
|
|
|
this.inputText += emoji;
|
|
|
this.showEmojiPanel = false;
|
|
|
@@ -1308,20 +1777,118 @@ export default {
|
|
|
title: '视频功能开发中',
|
|
|
icon: 'none'
|
|
|
});
|
|
|
- this.showMorePanel = false;
|
|
|
},
|
|
|
chooseFile() {
|
|
|
uni.showToast({
|
|
|
title: '文件功能开发中',
|
|
|
icon: 'none'
|
|
|
});
|
|
|
- this.showMorePanel = false;
|
|
|
},
|
|
|
- playVoice() {
|
|
|
- uni.showToast({
|
|
|
- title: '语音播放功能开发中',
|
|
|
- icon: 'none'
|
|
|
+ /**
|
|
|
+ * 切换语音播放/暂停
|
|
|
+ */
|
|
|
+ toggleVoicePlay(msg) {
|
|
|
+ // 如果正在播放这条语音,则暂停
|
|
|
+ if (this.playingVoiceId === msg.messageId) {
|
|
|
+ this.pauseVoice(msg);
|
|
|
+ } else {
|
|
|
+ // 否则开始播放
|
|
|
+ this.playVoice(msg);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 播放语音
|
|
|
+ */
|
|
|
+ playVoice(msg) {
|
|
|
+ console.log('📢 [播放语音] 开始 - messageId:', msg.messageId);
|
|
|
+
|
|
|
+ if (!msg.mediaUrl) {
|
|
|
+ // 如果是TIM消息,从payload中获取URL
|
|
|
+ const audioUrl = msg.payload?.url || msg.payload?.remoteAudioUrl;
|
|
|
+ if (!audioUrl) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '语音文件不存在',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ msg.mediaUrl = audioUrl;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果有其他语音正在播放,先停止
|
|
|
+ if (this.currentAudioContext) {
|
|
|
+ console.log('⏹️ 停止之前的语音');
|
|
|
+ this.currentAudioContext.stop();
|
|
|
+ this.currentAudioContext.destroy();
|
|
|
+ this.currentAudioContext = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除暂停状态
|
|
|
+ this.pausedVoiceId = null;
|
|
|
+
|
|
|
+ // 设置当前播放的语音ID,触发动画
|
|
|
+ this.playingVoiceId = msg.messageId;
|
|
|
+ console.log('🎬 [动画开始] playingVoiceId =', this.playingVoiceId);
|
|
|
+
|
|
|
+ // 创建音频上下文
|
|
|
+ const innerAudioContext = uni.createInnerAudioContext();
|
|
|
+ innerAudioContext.src = msg.mediaUrl;
|
|
|
+ innerAudioContext.loop = false; // 不循环播放
|
|
|
+ this.currentAudioContext = innerAudioContext;
|
|
|
+
|
|
|
+ innerAudioContext.onPlay(() => {
|
|
|
+ console.log('🔊 开始播放语音');
|
|
|
+ });
|
|
|
+
|
|
|
+ innerAudioContext.onEnded(() => {
|
|
|
+ console.log('✅ 语音播放完成');
|
|
|
+ this.playingVoiceId = null; // 停止动画
|
|
|
+ this.pausedVoiceId = null;
|
|
|
+ this.currentAudioContext = null;
|
|
|
+ console.log('🛑 [动画停止] playingVoiceId =', this.playingVoiceId);
|
|
|
+ innerAudioContext.destroy();
|
|
|
});
|
|
|
+
|
|
|
+ innerAudioContext.onError((err) => {
|
|
|
+ console.error('❌ 语音播放失败:', err);
|
|
|
+ this.playingVoiceId = null; // 停止动画
|
|
|
+ this.pausedVoiceId = null;
|
|
|
+ this.currentAudioContext = null;
|
|
|
+ console.log('🛑 [动画停止-错误] playingVoiceId =', this.playingVoiceId);
|
|
|
+ uni.showToast({
|
|
|
+ title: '播放失败',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ innerAudioContext.destroy();
|
|
|
+ });
|
|
|
+
|
|
|
+ innerAudioContext.play();
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 暂停语音
|
|
|
+ */
|
|
|
+ pauseVoice(msg) {
|
|
|
+ if (this.currentAudioContext) {
|
|
|
+ this.currentAudioContext.pause();
|
|
|
+ this.playingVoiceId = null; // 停止动画
|
|
|
+ this.pausedVoiceId = msg.messageId; // 设置暂停状态
|
|
|
+ console.log('⏸️ [暂停] playingVoiceId =', this.playingVoiceId, ', pausedVoiceId =', this.pausedVoiceId);
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 继续播放语音
|
|
|
+ */
|
|
|
+ resumeVoice() {
|
|
|
+ if (this.currentAudioContext && this.pausedVoiceId) {
|
|
|
+ this.currentAudioContext.play();
|
|
|
+ this.playingVoiceId = this.pausedVoiceId; // 恢复动画
|
|
|
+ const resumedId = this.pausedVoiceId;
|
|
|
+ this.pausedVoiceId = null; // 清除暂停状态
|
|
|
+ console.log('▶️ [继续播放] playingVoiceId =', this.playingVoiceId, ', pausedVoiceId =', this.pausedVoiceId);
|
|
|
+ }
|
|
|
},
|
|
|
showMoreOptions() {
|
|
|
this.showMoreOptionsModal = true;
|
|
|
@@ -1722,7 +2289,79 @@ export default {
|
|
|
.message-voice {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- gap: 10rpx;
|
|
|
+ gap: 12rpx;
|
|
|
+ padding: 0rpx 0;
|
|
|
+ min-width: 30rpx;
|
|
|
+ max-width: 400rpx;
|
|
|
+ cursor: pointer;
|
|
|
+ -webkit-tap-highlight-color: transparent;
|
|
|
+ outline: none;
|
|
|
+ border: none;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-duration {
|
|
|
+ font-size: 27rpx;
|
|
|
+ color: #333;
|
|
|
+ flex-shrink: 0;
|
|
|
+ line-height: 1;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-icon-wrapper {
|
|
|
+ display: flex;
|
|
|
+ flex-shrink: 0;
|
|
|
+ line-height: 0;
|
|
|
+ position: relative;
|
|
|
+ top: 1rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-icon {
|
|
|
+ width: 22rpx;
|
|
|
+ height: 22rpx;
|
|
|
+ display: block;
|
|
|
+ transform: none;
|
|
|
+ animation: none;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-icon-wrapper.playing .voice-icon {
|
|
|
+ animation: voice-bounce 0.6s ease-in-out infinite;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-resume-btn {
|
|
|
+ position: absolute;
|
|
|
+ top: 50%;
|
|
|
+ transform: translateY(-50%);
|
|
|
+ width: 32rpx;
|
|
|
+ height: 32rpx;
|
|
|
+ background: #07c160;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(7, 193, 96, 0.3);
|
|
|
+}
|
|
|
+
|
|
|
+.voice-resume-btn.resume-btn-left {
|
|
|
+ left: -40rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-resume-btn.resume-btn-right {
|
|
|
+ right: -40rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.resume-icon {
|
|
|
+ color: #fff;
|
|
|
+ font-size: 16rpx;
|
|
|
+ margin-left: 2rpx;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes voice-bounce {
|
|
|
+ 0%, 100% {
|
|
|
+ transform: scale(1);
|
|
|
+ }
|
|
|
+ 50% {
|
|
|
+ transform: scale(1.2);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
.message-video {
|
|
|
@@ -1777,6 +2416,24 @@ export default {
|
|
|
font-size: 50rpx;
|
|
|
}
|
|
|
|
|
|
+.send-button {
|
|
|
+ padding: 0 30rpx;
|
|
|
+ height: 70rpx;
|
|
|
+ line-height: 70rpx;
|
|
|
+ background-color: #07c160;
|
|
|
+ color: #fff;
|
|
|
+ border-radius: 8rpx;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 28rpx;
|
|
|
+ margin-left: 10rpx;
|
|
|
+ transition: opacity 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.send-button.disabled {
|
|
|
+ opacity: 0.5;
|
|
|
+ background-color: #95ec69;
|
|
|
+}
|
|
|
+
|
|
|
.input-field {
|
|
|
flex: 1;
|
|
|
height: 70rpx;
|
|
|
@@ -1961,6 +2618,93 @@ export default {
|
|
|
.vip-link:active {
|
|
|
opacity: 0.7; /* 点击时透明度降低 */
|
|
|
}
|
|
|
+
|
|
|
+/* 录音中提示遮罩 */
|
|
|
+.voice-recording-mask {
|
|
|
+ position: fixed;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background-color: rgba(0, 0, 0, 0.6);
|
|
|
+ z-index: 10000;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+/* 录音提示框 */
|
|
|
+.voice-recording-box {
|
|
|
+ width: 300rpx;
|
|
|
+ height: 300rpx;
|
|
|
+ background-color: rgba(0, 0, 0, 0.8);
|
|
|
+ border-radius: 20rpx;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #fff;
|
|
|
+ transition: background-color 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-recording-box.canceling {
|
|
|
+ background-color: rgba(220, 38, 38, 0.9);
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+.voice-wave-container {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 8rpx;
|
|
|
+ height: 100rpx;
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-wave-bar {
|
|
|
+ width: 6rpx;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 3rpx;
|
|
|
+ transition: transform 0.1s ease-out;
|
|
|
+ transform-origin: center;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-wave-bar.bar1 {
|
|
|
+ height: 30rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-wave-bar.bar2 {
|
|
|
+ height: 45rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-wave-bar.bar3 {
|
|
|
+ height: 60rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-wave-bar.bar4 {
|
|
|
+ height: 50rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-wave-bar.bar5 {
|
|
|
+ height: 35rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-text {
|
|
|
+ font-size: 32rpx;
|
|
|
+ margin-bottom: 10rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-time {
|
|
|
+ font-size: 48rpx;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #07c160;
|
|
|
+ margin: 10rpx 0;
|
|
|
+}
|
|
|
+
|
|
|
+.voice-tip {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: #ccc;
|
|
|
+}
|
|
|
</style>
|
|
|
|
|
|
|