Files
uniapp-im-shop/TUIKit/components/TUIChat/message-input/message-input-audio.vue
2025-12-30 23:28:59 +08:00

618 lines
16 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
:class="{
'message-input-audio': true,
'message-input-audio-open': isAudioTouchBarShow,
}"
>
<Icon
class="audio-message-icon"
:file="audioIcon"
:size="'23px'"
:hotAreaSize="'3px'"
@onClick="switchAudio"
/>
<view
v-if="props.isEnableAudio"
class="audio-input-touch-bar"
@longpress="handleLongPress"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<span>{{ TUITranslateService.t(`TUIChat.${touchBarText}`) }}</span>
<view
v-if="isRecording"
class="record-modal"
>
<div class="red-mask" />
<view class="float-element moving-slider" />
<view class="float-element modal-title">
{{ TUITranslateService.t(`TUIChat.${modalText}`) }}
</view>
</view>
</view>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from '../../../adapter-vue';
import {
TUIStore,
StoreName,
TUIChatService,
SendMessageParams,
IConversationModel,
TUITranslateService,
} from '@tencentcloud/chat-uikit-engine-lite';
import { TUIGlobal } from '@tencentcloud/universal-api';
import Icon from '../../common/Icon.vue';
import audioIcon from '../../../assets/icon/audio.svg';
import { Toast, TOAST_TYPE } from '../../common/Toast/index';
import { throttle } from '../../../utils/lodash';
import { isEnabledMessageReadReceiptGlobal } from '../utils/utils';
import { InputDisplayType } from '../../../interface';
// #ifdef APP-PLUS
import {
judgeIosPermissionRecord,
requestAndroidPermission,
gotoAppPermissionSetting,
checkPermissionStatusInApp,
} from '../../../utils/permission.js';
// #endif
interface IProps {
isEnableAudio: boolean;
}
interface IEmits {
(e: 'changeDisplayType', type: InputDisplayType): void;
}
interface RecordResult {
tempFilePath: string;
duration?: number;
fileSize?: number;
}
type TouchBarText = '按住说话' | '抬起发送' | '抬起取消';
type ModalText = '正在录音' | '继续上滑可取消' | '松开手指 取消发送';
// 麦克风权限状态枚举
enum MicPermissionStatus {
UNKNOWN = 'unknown', // 未知
AUTHORIZED = 'authorized', // 已授权
NOT_DETERMINED = 'not_determined', // 未确定(首次)
DENIED = 'denied', // 已拒绝(可再次申请)
DENIED_ALWAYS = 'denied_always', // 永久拒绝
}
const emits = defineEmits<IEmits>();
const props = withDefaults(defineProps<IProps>(), {
isEnableAudio: false,
});
let recordTime: number = 0;
let isManualCancelBySlide = false;
let recordTimer: number | undefined;
let firstTouchPageY: number = -1;
const recorderManager = TUIGlobal?.getRecorderManager();
const isRecording = ref(false);
const touchBarText = ref<TouchBarText>('按住说话');
const modalText = ref<ModalText>('正在录音');
const isAudioTouchBarShow = ref<boolean>(false);
const currentConversation = ref<IConversationModel>();
const recordConfig = {
duration: 60000,
sampleRate: 44100,
numberOfChannels: 1,
encodeBitRate: 192000,
format: 'mp3',
};
/**
* 处理权限检查结果并执行相应操作
* @param {Function} onAuthorized 已授权时的回调
*/
async function handlePermissionCheck(onAuthorized: () => void) {
const status = await getRecordPermissionStatus();
switch (status) {
case MicPermissionStatus.AUTHORIZED:
onAuthorized();
break;
case MicPermissionStatus.DENIED_ALWAYS:
showDeniedAlwaysDialog();
break;
case MicPermissionStatus.NOT_DETERMINED:
showAuthorizationDialog(async () => {
const granted = await requestMicrophonePermission();
if (granted) {
// 授权成功,执行回调
onAuthorized();
} else {
// 授权失败,再次检查状态
const newStatus = await getRecordPermissionStatus();
if (newStatus === MicPermissionStatus.DENIED_ALWAYS) {
showDeniedAlwaysDialog();
}
}
});
break;
default:
Toast({
message: TUITranslateService.t('TUIChat.获取麦克风权限状态失败,请重试'),
type: TOAST_TYPE.ERROR,
});
break;
}
}
/**
* 获取录音权限状态APP 端)
* @returns {Promise<MicPermissionStatus>} 权限状态
*/
async function getRecordPermissionStatus(): Promise<MicPermissionStatus> {
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync();
if (systemInfo.platform === 'android') {
// Android 端:使用 requestAndroidPermission 检查权限
try {
const checkResult = checkPermissionStatusInApp('RECORD');
if (checkResult !== 1) {
// 将 showModal 包装为 Promise以便等待用户操作结果
return await new Promise<MicPermissionStatus>((resolve) => {
uni.showModal({
title: TUITranslateService.t('TUIChat.权限申请'),
content: TUITranslateService.t('TUIChat.请允许使用麦克风权限用于发送语音消息'),
success: async function (res) {
if (res.confirm) {
const result = await requestAndroidPermission('android.permission.RECORD_AUDIO');
if (result === 1) {
resolve(MicPermissionStatus.AUTHORIZED);
} else if (result === -1) {
resolve(MicPermissionStatus.DENIED_ALWAYS);
} else if (result === 0) {
resolve(MicPermissionStatus.NOT_DETERMINED);
} else {
resolve(MicPermissionStatus.UNKNOWN);
}
} else if (res.cancel) {
resolve(MicPermissionStatus.DENIED);
}
},
fail: () => {
resolve(MicPermissionStatus.UNKNOWN);
}
});
});
} else {
return MicPermissionStatus.AUTHORIZED;
}
} catch (error) {
console.error('[Audio] Android: 获取权限状态失败:', error);
return MicPermissionStatus.UNKNOWN;
}
} else if (systemInfo.platform === 'ios') {
try {
const hasPermission = judgeIosPermissionRecord();
if (hasPermission) {
return MicPermissionStatus.AUTHORIZED;
} else {
// 需要进一步判断是首次还是已拒绝
const AVAudioSession = plus.ios.importClass('AVAudioSession');
const audioSession = AVAudioSession.sharedInstance();
const permissionStatus = audioSession.recordPermission();
plus.ios.deleteObject(audioSession);
// 1684369017: denied, 1970168948: not determined
if (permissionStatus === 1684369017) {
return MicPermissionStatus.DENIED_ALWAYS;
} else {
return MicPermissionStatus.NOT_DETERMINED;
}
}
} catch (error) {
return MicPermissionStatus.UNKNOWN;
}
}
// #endif
// #ifdef MP
return new Promise((resolve, reject) => {
uni.getSetting({
success(res) {
if (res.authSetting['scope.record'] === false) {
resolve(MicPermissionStatus.DENIED_ALWAYS);
} else if (res.authSetting['scope.record'] === undefined) {
resolve(MicPermissionStatus.NOT_DETERMINED);
} else if (res.authSetting['scope.record'] === true) {
resolve(MicPermissionStatus.AUTHORIZED);
} else {
resolve(MicPermissionStatus.UNKNOWN);
}
},
fail() {
reject();
}
});
});
// #endif
return MicPermissionStatus.UNKNOWN;
}
/**
* 请求麦克风权限(弹出系统授权窗)
* @returns {Promise<boolean>} 是否授权成功
*/
async function requestMicrophonePermission(): Promise<boolean> {
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync();
if (systemInfo.platform === 'android') {
const result = await requestAndroidPermission('android.permission.RECORD_AUDIO');
if (result === 1) {
return true;
} else if (result === -1) {
return false;
} else {
return false;
}
} else if (systemInfo.platform === 'ios') {
return new Promise((resolve) => {
try {
const AVAudioSession = plus.ios.importClass('AVAudioSession');
const audioSession = AVAudioSession.sharedInstance();
audioSession.requestRecordPermission((granted: boolean) => {
plus.ios.deleteObject(audioSession);
if (granted) {
resolve(true);
} else {
resolve(false);
}
});
} catch (error) {
console.error('[Audio] iOS: 请求权限失败:', error);
resolve(false);
}
});
}
// #endif
// #ifdef MP
uni.authorize({
scope: 'scope.record',
success() {}
})
// #endif
return false;
}
/**
* 打开应用权限设置页面
*/
function openAppPermissionSetting() {
// #ifdef APP-PLUS
gotoAppPermissionSetting();
// #endif
}
/**
* 显示永久拒绝提示弹窗
*/
function showDeniedAlwaysDialog() {
let message = '';
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync();
if (systemInfo.platform === 'android') {
message = TUITranslateService.t('TUIChat.麦克风权限已被拒绝,请前往"设置 → 应用信息 → 权限"中开启麦克风权限');
} else if (systemInfo.platform === 'ios') {
message = TUITranslateService.t('TUIChat.麦克风权限已被拒绝,请前往"设置 → 隐私 → 麦克风"中开启权限');
}
// #endif
// #ifdef MP
message = TUITranslateService.t('TUIChat.麦克风权限已被拒绝,请前往"右上角设置"中开启权限');
// #endif
uni.showModal({
title: TUITranslateService.t('TUIChat.需要麦克风权限'),
content: message,
confirmText: TUITranslateService.t('TUIChat.去设置'),
cancelText: TUITranslateService.t('TUIChat.取消'),
success: (res: any) => {
if (res.confirm) {
// #ifdef APP-PLUS
openAppPermissionSetting();
// #endif
// #ifdef MP
uni.openSetting();
// #endif
} else {
// 用户取消,切换回文本输入模式
emits('changeDisplayType', 'editor');
}
},
});
}
/**
* 显示首次授权提示弹窗
* @param {Function} onConfirm 用户点击授权后的回调
*/
function showAuthorizationDialog(onConfirm: () => void) {
uni.showModal({
title: TUITranslateService.t('TUIChat.需要麦克风权限'),
content: TUITranslateService.t('TUIChat.需要您授权麦克风权限以使用语音功能'),
confirmText: TUITranslateService.t('TUIChat.授权'),
cancelText: TUITranslateService.t('TUIChat.取消'),
success: (res: any) => {
if (res.confirm) {
onConfirm();
} else {
emits('changeDisplayType', 'editor');
}
},
});
}
/**
* 切换音频输入模式(点击麦克风图标)
*/
async function switchAudio() {
if (props.isEnableAudio) {
// 当前是音频模式,切换回文本模式
emits('changeDisplayType', 'editor');
} else {
// 切换到音频模式前,先检查权限
await handlePermissionCheck(() => {
// 权限通过,切换到音频模式
emits('changeDisplayType', 'audio');
});
}
}
/**
* 长按开始录音(作为兜底检查)
*/
async function handleLongPress() {
// 检查权限(作为兜底)
await handlePermissionCheck(() => {
// 权限通过,开始录音
startRecording();
});
}
/**
* 开始录音
*/
function startRecording() {
recorderManager.start(recordConfig);
}
onMounted(() => {
// 注册录音管理器事件
recorderManager.onStart(onRecorderStart);
recorderManager.onStop(onRecorderStop);
recorderManager.onError(onRecorderError);
TUIStore.watch(StoreName.CONV, {
currentConversation: onCurrentConverstaionUpdated,
});
});
onUnmounted(() => {
TUIStore.unwatch(StoreName.CONV, {
currentConversation: onCurrentConverstaionUpdated,
});
});
function onCurrentConverstaionUpdated(conversation: IConversationModel) {
currentConversation.value = conversation;
}
function initRecorder() {
initRecorderData();
initRecorderView();
}
function initRecorderView() {
isRecording.value = false;
touchBarText.value = TUITranslateService.t('TUIChat.按住说话');
modalText.value = TUITranslateService.t('TUIChat.正在录音');
}
function initRecorderData(options?: { hasError: boolean }) {
clearInterval(recordTimer);
recordTimer = undefined;
recordTime = 0;
firstTouchPageY = -1;
isManualCancelBySlide = false;
if (!options?.hasError) {
recorderManager.stop();
}
}
const handleTouchMove = throttle(function (e: any) {
if (isRecording.value) {
const pageY = e.changedTouches[e.changedTouches.length - 1].pageY;
if (firstTouchPageY < 0) {
firstTouchPageY = pageY;
}
const offset = (firstTouchPageY as number) - pageY;
if (offset > 150) {
touchBarText.value = TUITranslateService.t('TUIChat.抬起取消');
modalText.value = TUITranslateService.t('TUIChat.松开手指 取消发送');
isManualCancelBySlide = true;
} else if (offset > 50) {
touchBarText.value = TUITranslateService.t('TUIChat.抬起发送');
modalText.value = TUITranslateService.t('TUIChat.继续上滑可取消');
isManualCancelBySlide = false;
} else {
touchBarText.value = TUITranslateService.t('TUIChat.抬起发送');
modalText.value = TUITranslateService.t('TUIChat.正在录音');
isManualCancelBySlide = false;
}
}
}, 100);
function handleTouchEnd() {
recorderManager.stop();
}
function onRecorderStart() {
recordTimer = setInterval(() => {
recordTime += 1;
}, 1000);
touchBarText.value = TUITranslateService.t('TUIChat.抬起发送');
isRecording.value = true;
}
function onRecorderStop(res: RecordResult) {
if (isManualCancelBySlide || !isRecording.value) {
initRecorder();
return;
}
clearInterval(recordTimer);
const tempFilePath = res.tempFilePath;
const duration = res.duration ? res.duration : recordTime * 1000;
const fileSize = res.fileSize ? res.fileSize : ((48 * recordTime) / 8) * 1024;
if (duration < 1000) {
Toast({
message: TUITranslateService.t('TUIChat.录音时间太短'),
type: TOAST_TYPE.NORMAL,
duration: 1500,
});
} else {
const options = {
to:
currentConversation?.value?.groupProfile?.groupID
|| currentConversation?.value?.userProfile?.userID,
conversationType: currentConversation?.value?.type,
payload: { file: { duration, tempFilePath, fileSize } },
needReadReceipt: isEnabledMessageReadReceiptGlobal(),
} as SendMessageParams;
TUIChatService?.sendAudioMessage(options);
}
initRecorder();
}
function onRecorderError(err: any) {
console.error('[Audio] Recorder error:', err);
initRecorderData({ hasError: true });
initRecorderView();
// 根据错误码判断是否是权限问题
if (err?.errMsg?.includes('auth') || err?.errMsg?.includes('permission')) {
showDeniedAlwaysDialog();
} else {
Toast({
message: err?.errMsg || TUITranslateService.t('TUIChat.录音失败,请重试'),
type: TOAST_TYPE.ERROR,
});
}
}
</script>
<style lang="scss" scoped>
@import "../../../assets/styles/common";
.message-input-audio {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
.audio-message-icon {
margin-right: 3px;
}
.audio-input-touch-bar {
height: 39px;
flex: 1;
border-radius: 10px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: #fff;
.record-modal {
height: 300rpx;
width: 60vw;
background-color: rgba(0, 0, 0, 0.8);
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
border-radius: 24rpx;
display: flex;
flex-direction: column;
overflow: hidden;
.red-mask {
position: absolute;
inset: 0;
background-color: rgba(#ff3e48, 0.5);
opacity: 0;
transition: opacity 10ms linear;
z-index: 1;
}
.moving-slider {
margin: 10vw;
width: 40rpx;
height: 16rpx;
border-radius: 4rpx;
background-color: #006fff;
animation: loading 1s ease-in-out infinite alternate;
z-index: 2;
}
.float-element {
position: relative;
z-index: 2;
}
}
@keyframes loading {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(30vw, 0);
background-color: #f5634a;
width: 40px;
}
}
.modal-title {
text-align: center;
color: #fff;
}
}
&-open {
flex: 1;
}
}
</style>