Files
tra-app/src/components/touch-btn/touch-btn.vue
T

664 lines
21 KiB
Vue
Raw 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>
<view>
<view class="talk-btns-components" :class="{ action: isRecording, inputMode: !isInputVisible }">
<view class="touch-btn" @click="disabled_click">
<button
v-if="isInputVisible"
class="voice-btn"
@touchstart="startRecord"
@touchend="stopRecord()"
@touchmove="handleMove"
@touchcancel="stopRecord()"
:plain="true"
>
<text class="voice-text">{{ isRecording ? '松开发送' : '按住说话' }}</text>
</button>
<input
v-if="!isVoiceOnlyMode && !isInputVisible"
class="input-box"
ref="inputRef"
v-model="inputText"
:maxlength="props.maxlength"
:adjustPosition="props.adjustPosition"
placeholderStyle="color:#999999;font-size:26rpx"
placeholder="请输入文字"
:disabled="props.isDisabled"
@click="disabled_click"
/>
</view>
<view class="talk-right-icon-div" v-if="!isVoiceOnlyMode">
<image class="talk-right-icon" :src="talkRightIcon" mode="widthFix" @click="iconClick()"></image>
</view>
</view>
<uni-popup ref="popupRef" class="popup" @touchmove.stop.prevent="false">
<view class="overlay-box">
<view class="fl1"></view>
<view class="talk-dialog-box">
<view class="talk-dialog-div" :class="dialogDynamicClasses">
<view class="talk-dialog">
<image :src="TouchBtnImages.voiceWhiteBtn" mode="" class="gif-box"></image>
<uv-textarea
class="textarea"
ref="inputRef"
v-model="textAreaText"
:maxlength="props.maxlength"
@click.stop
customStyle="background: #ffffff00; padding-bottom: 50rpx; color: #fff; caret-color: #fff"
text-style="color: #fff"
border="none"
placeholderStyle="color:#999999;font-size:26rpx;"
placeholder=""
></uv-textarea>
<view class="error-tip-div">
<image :src="TouchBtnImages.iconRecognizeErrorSvg()" mode="aspectFill" class="img"></image>
<view class="" v-if="voice_to_text_status === 'failed'">未识别到文字</view>
<view class="" v-if="voice_to_text_status === 'networkError'">网络识别超时</view>
</view>
</view>
</view>
</view>
<!-- 第一形态两侧按钮 -->
<view class="page-box" :style="{ marginBottom: pageBoxMarginBottom }">
<view class="page-1" :class="[status, workMode]">
<view class="btns-box">
<!-- 左侧取消按钮 -->
<image
class="btn-box btn-box-1"
:src="isOutStatus == '02' ? TouchBtnImages.cancelActiveBtn : TouchBtnImages.cancelBtn"
mode="heightFix"
@click="closePopup()"
></image>
<!-- 右侧转文字按钮 -->
<image
class="btn-box btn-box-2"
:src="isOutStatus == '03' ? TouchBtnImages.translateActiveBtn : TouchBtnImages.translateBtn"
mode="heightFix"
></image>
<!-- 当仅语音的时候取消按钮图案 -->
<image
class="btn-box-3"
:src="isOutStatus == '02' ? TouchBtnImages.closeActiveBtn : TouchBtnImages.closeBtn"
mode="heightFix"
@click="closePopup()"
></image>
</view>
<!-- 下方按钮 -->
<view class="circle-bg-box">
<view class="tips-box">{{ workMode === 'text-only' ? '松开转文字' : '松开发送' }}</view>
</view>
</view>
<!-- 第二形态 -->
<view class="page-2" :class="[status, voice_to_text_status, workMode]">
<!-- 左侧XX -->
<view class="transVoiceIng cancel-btn" @click="closePopup()">
<text class="btn-text">取消</text>
</view>
<!-- 中间发送原语音 -->
<view class="transVoiceIng trans-btn" @click="originalSend()">
<text class="btn-text">发送原语音</text>
</view>
<!-- 右侧发送按钮椭圆 -->
<view class="send-btn" @click="sendMsg()">
<view class="send-btn-text">发送</view>
<CommonLoading class="send-btn-loading" color="#444" />
</view>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import * as TouchBtnImages from './touch-btn.images'; // 导出图片
import { StatusType, VoiceToTextSubStatus, ErrorCode, WorkModeType } from './touch-btn.types';
import type { TouchBtnEmits } from './touch-btn.types';
import { wsUrl, bottomAreaTopBoundary, horizontalSplitX, initWebSocket, showErrorTip, vibrateLight } from './touch-btn';
import { ref, computed, onMounted, nextTick, watch, reactive } from 'vue';
import { onHide, onLaunch } from '@dcloudio/uni-app';
import common, { setupKeyboardHeightListener, getToken } from '@/common/common';
import { startAudioRecord, stopAudioRecord, preRequestRecordPermission } from '@/uni_modules/ty-recording';
import { ProtocolCodec, MessageType, ControlCommand, ControlCode, ControlBody } from '@/common/protocolCodec';
import permissionApi from '@/common/permission';
const emit = defineEmits<TouchBtnEmits>();
const pageBoxMarginBottom = ref('0px'); // 适配输入法
const ws = ref(null);
const wsStatus = ref(false);
const status = ref<StatusType>('default');
const voice_to_text_status = ref<VoiceToTextSubStatus>('init');
const popupRef = ref(null);
const props = defineProps({
isDisabled: {
default: false,
type: Boolean
},
// 禁用时候点击反馈的文本
disabledText: {
default: '请稍后再试!',
type: String
},
isLoading: {
default: false,
type: Boolean
},
loadingText: {
default: '发送回答中,请稍后再试!',
type: String
},
// 设置为true则不能发送消息
canAnswer: {
default: false,
type: Boolean
},
// 不能发送消息的提示
canAnswerText: {
default: '当前状态不能发送内容!',
type: String
},
maxlength: {
type: Number,
default: 500
},
// 最小录音时长
minRecordDuration: {
type: Number,
default: 1000
},
// 最大录音时长
maxRecordDuration: {
type: Number,
default: 60000
},
adjustPosition: {
default: false,
type: Boolean
},
answerMethod: {
type: String,
default: '01',
validator: (value: string) => {
/* '01': '不限制回答方式', '02': '仅文字回答', '03': '仅语音回答'*/
return ['01', '02', '03'].includes(value);
}
}
});
// 兼容以前的按钮参数
const workMode = computed<WorkModeType>(() => {
const modeMap = {
'01': 'both',
'02': 'text-only',
'03': 'voice-only'
};
return modeMap[props.answerMethod] || 'both';
});
const textAreaText = computed({
get() {
return allText.value + tempText.value;
},
set(newValue) {
allText.value = newValue;
}
});
const dialogDynamicClasses = computed(() => {
const classes = [];
isOutStatus.value === '02' && classes.push('is-out');
(isOutStatus.value === '03' || status.value === 'voice_to_text' || status.value === 'send_over') &&
classes.push('is-translate');
// 仅语音模式,01松手后要和03操作一样
// isOutStatus.value === '01' && workMode.value === 'text-only' && status.value == 'voice_to_text' && classes.push('is-translate');
voice_to_text_status.value !== '' && classes.push(voice_to_text_status.value);
return classes;
});
const recordId = ref('');
const allText = ref('');
const tempText = ref('');
const inputText = ref('');
const ossKey = ref('');
let voiceQueue = [];
// 记录 按下移动位置
const touchPos = reactive({ x: 0, y: 0 });
// 是否录音中
const isRecording = ref(false);
let voiceTimer = null; // 兜底关闭的定时器全局唯一
// 键盘还是语音
const isInputVisible = ref(true); // true=显示录音按钮,false=显示输入框
const recordStartTime = ref(0); // 录音开始时间戳
let pressTimer = null;
// 时长自动关闭定时器
let durationTimer = null;
// 计算属性:判断是否仅语音/仅文字模式(简化模板逻辑)
const isVoiceOnlyMode = computed(() => workMode.value === 'voice-only');
const isTextOnlyMode = computed(() => workMode.value === 'text-only');
const talkRightIcon = computed(() => {
if (isInputVisible.value) return TouchBtnImages.icKeyboardSvg();
if (inputText.value) return TouchBtnImages.iconSendSvg();
return TouchBtnImages.iconMicSvg();
});
// 计算是否超出按下盒子位置01代表下方盒子,02代表左上03代表右上
const isOutStatus = computed(() => {
const { y: touchY, x: touchX } = touchPos;
if (touchY >= bottomAreaTopBoundary) return '01';
// 仅语音模式只能返回取消(02
if (workMode.value === 'voice-only') return '02';
return touchX < horizontalSplitX ? '02' : '03';
});
// 监听按下移动
const handleMove = (obj) => {
const { pageX, pageY } = obj.changedTouches[0];
touchPos.x = pageX;
touchPos.y = pageY;
};
const testFun = () => {
voice_to_text_status.value = 'failed';
};
// 开启语音
const startRecord = async (obj: { touches: any[] }) => {
if (props.isDisabled) return;
if (props.isLoading) return common.msg(props.loadingText);
if (pressTimer || isRecording.value) return;
if (props.canAnswer) return common.msg(props.canAnswerText);
try {
const state = await permissionApi.judgeIosPermission('record');
if (state === 'not determined') {
// 苹果专用
preRequestRecordPermission();
return;
}
} catch (e) {
console.error('麦克风权限申请失败:', e);
return;
}
// 标记录音开始
isRecording.value = true;
status.value = 'default';
voice_to_text_status.value = '';
tempText.value = '';
allText.value = '';
ossKey.value = '';
pressTimer = setTimeout(() => {
if (!isRecording.value) {
console.log('定时器被取消还是进入了');
return;
}
pressTimer = null;
status.value = 'voice_recording';
console.log('触感震动');
vibrateLight(); // 触感震动
// 重设按下位置
const changedTouche = obj.touches[0];
touchPos.x = changedTouche.clientX;
touchPos.y = changedTouche.clientY;
// 记录开始时间戳(毫秒)
recordStartTime.value = Date.now();
// 弹出录音界面
startRecordingTimer(true); // 启动超长定时器
startAudioRecord({
onFrame: (pcmData: any) => {
if (wsStatus.value && ws.value) {
if (voiceQueue.length > 0) {
voiceQueue.forEach((cachedData) => {
ws.value.send({
data: ProtocolCodec.pack(MessageType.AUDIO_DATA, cachedData)
});
});
// 发送完成后清空队列
voiceQueue = [];
}
// 再发送当前帧的音频数据
ws.value.send({
data: ProtocolCodec.pack(MessageType.AUDIO_DATA, pcmData)
});
} else {
voiceQueue.push(pcmData)
}
},
onStart: (res: any) => {
console.log('启动', res);
recordId.value = res.recordId ?? '';
popupRef.value.open();
console.log('开启语音');
setTimeout(() => {
initWebSocket(wsUrl + '?summary=' + getToken())
.then((socketTask) => {
if (!isRecording.value) {
ws.value = null;
wsStatus.value = false;
return;
}
ws.value = socketTask;
wsStatus.value = true;
console.log('WS连接成功', isRecording.value);
ws.value.onMessage((res: { data: Uint8Array | ArrayBuffer }) => {
try {
const { msgType, body } = ProtocolCodec.unpack(res.data);
console.log('msgType', msgType, body);
if (msgType === MessageType.TEXT_MESSAGE) {
tempText.value = '';
allText.value = allText.value + body;
} else if (msgType === MessageType.TIP_MESSAGE) {
tempText.value += body as string;
} else if (msgType === MessageType.CONTROL_CMD) {
// 返回结束消息
const controlBody = body as ControlBody;
if (controlBody.type === ControlCode.SERVER_TO_FRONT_OVER) {
console.log(
'识别结束',
controlBody,
voice_to_text_status.value,
status.value,
voice_to_text_status.value
);
// 如果不等于init说明已经超时,被超时函数处理完了,这里即使返回也不做处理
if (
status.value === 'voice_to_text' &&
voice_to_text_status.value !== 'init'
)
return;
if (status.value === 'send_over') return;
// 时间太短,关掉的时候就处理了,这里不处理
const recordDurationMs = Date.now() - recordStartTime.value;
if (recordDurationMs <= props.minRecordDuration) return;
closeWS();
status.value = 'send_over'; // 设置成发送完成
if (voiceTimer) clearTimeout(voiceTimer);
// 设置识别结果
voice_to_text_status.value = controlBody?.txt === '' ? 'failed' : 'success';
// 取值出来
ossKey.value = controlBody?.ossKey || '';
if (typeof controlBody?.txt === 'string') {
allText.value = controlBody.txt.trim();
}
const _isOutStatus =
workMode.value === 'text-only' && isOutStatus.value === '01'
? '03'
: isOutStatus.value;
// 只有模式为01的时候才发送出去
console.log('只有模式为01的时候才发送出去', controlBody);
if (_isOutStatus === '01') {
emit('submitVoice', {
ossKey: ossKey.value,
text: allText.value,
recordId: recordId.value
});
}
}
}
} catch (e) {
console.log('后端发来的信息有问题', e, res.data);
}
});
})
.catch(() => {
stopRecord('networkError');
console.log('网络出现问题');
});
}, 200);
},
onStop: () => {
console.log('关闭');
},
onError: ({ errCode, errMsg }) => {
console.log('onError', errMsg);
if (errCode === 9010001) {
console.log(errMsg);
}
}
});
}, 140);
};
// 结束录音
const closePopup = () => {
startRecordingTimer(false);
isRecording.value = false;
popupRef.value.close();
};
// 结束ws
const closeWS = () => {
if (ws.value) {
ws.value.close({
code: 1000,
reason: '挂断',
complete: () => {
console.log('挂断Ws');
wsStatus.value = false;
ws.value = null;
}
});
}
};
const stopRecord = async (errrStatus = '') => {
console.log('松手stopRecord', pressTimer, isRecording.value, status.value);
if (pressTimer) {
clearTimeout(pressTimer);
console.log('取消一个存在的定时器');
pressTimer = null;
isRecording.value = false;
setTimeout(() => {
status.value = 'default';
}, 50);
return;
}
if (!isRecording.value) return; // 未处于录音状态
if (status.value !== 'voice_recording') {
closeWS();
closePopup();
return;
}
// 核心:计算录音总时长(毫秒转秒,保留1位小数)
const recordDurationMs = Date.now() - recordStartTime.value;
console.log('recordDurationMs', recordDurationMs);
// 拖动到转文字按钮上抬手或者仅文字模式的发送也强行算他转文字
const _isOutStatus = workMode.value === 'text-only' && isOutStatus.value === '01' ? '03' : isOutStatus.value;
// 时间太短03也直接关了
if (_isOutStatus === '03' && !(recordDurationMs <= props.minRecordDuration)) {
console.log('时间不短进来了');
status.value = 'voice_to_text'; //主状态设置为转文字
voice_to_text_status.value = 'init';
if (voiceTimer) clearTimeout(voiceTimer);
voiceTimer = setTimeout(() => {
voiceTimer = null;
console.log('3号定时器', voice_to_text_status.value, status.value);
if (voice_to_text_status.value !== 'init') return;
if (status.value === 'send_over') return;
voice_to_text_status.value = 'networkError';
status.value = 'send_over'; // 设置成发送完成
}, 4000);
} else if (_isOutStatus === '01') {
status.value = 'voice_send_ing'; //主状态设置为语音发送中
}
if ((_isOutStatus !== '03' || recordDurationMs <= props.minRecordDuration) && recordDurationMs < 640) {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 640 - recordDurationMs);
});
}
// 取消的时候自己主动关闭websocket,其他的时候需要等待后端的消息关闭
if (_isOutStatus === '02' || errrStatus === 'networkError') {
closeWS();
}
// 向后端发送抬手动作
if (['01', '03'].includes(_isOutStatus) && ws.value) {
console.log('向后端发送抬手动作');
ws.value.send({
data: ProtocolCodec.pack(MessageType.CONTROL_CMD, { type: ControlCode.FRONT_TO_SERVER_OVER })
});
}
stopAudioRecord(); // 关掉录音
isRecording.value = false;
console.log('_isOutStatus', _isOutStatus);
if (_isOutStatus !== '03' || errrStatus === 'networkError' || recordDurationMs <= props.minRecordDuration) {
// 转文字时候不关遮罩层,网络错误必须关遮罩层
console.log('转文字时候不关遮罩层,网络错误必须关遮罩层');
closePopup();
// 关掉弹窗后的提示
if (errrStatus === 'networkError' && _isOutStatus === '01') {
status.value = 'send_over'; // 设置成发送完成
emit('submitVoice', { ossKey: '', text: '', recordId: recordId.value });
return;
} else if (recordDurationMs <= props.minRecordDuration) {
console.log('进入这里了', status.value);
if (_isOutStatus !== '02') {
showErrorTip(ErrorCode.ShortRecord);
}
setTimeout(() => {
status.value = 'send_over'; // 设置成发送完成
}, 200);
return;
}
}
if (_isOutStatus === '01' && status.value == 'voice_send_ing') {
emit('sendLoading', { recordId: recordId.value });
// 如果socket长时间没有返回,这里兜底一下
if (voiceTimer) clearTimeout(voiceTimer);
voiceTimer = setTimeout(() => {
console.log('1号定时器', voice_to_text_status.value, status.value);
voiceTimer = null;
if (workMode.value === 'voice-only' || (workMode.value === 'both' && _isOutStatus === '01')) {
if (status.value === 'send_over') return;
status.value = 'send_over'; // 恢复成默认状态
console.log('1号定时器纠错');
emit('submitVoice', { ossKey: '', text: '', recordId: recordId.value });
}
}, 4000);
}
};
// 发送文本
const showKeyboard = () => {
if (isInputVisible.value) {
const inputTextTram = inputText.value.trim();
if (inputTextTram) {
// emit('submit', 'text', inputTextTram);
} else {
common.msg('不能发送空白消息!');
}
} else {
isInputVisible.value = !isInputVisible.value;
}
};
// 切换键盘语音
const changeBtn = (_isInputVisible = true) => {
if (props.isDisabled) return;
// 只要不是仅录音,就可以切换到录音按钮
if (workMode.value === 'text-only') {
isInputVisible.value = false;
}
// 否则为true
isInputVisible.value = _isInputVisible;
inputText.value = '';
};
// 按钮右侧图标(三个功能)
const iconClick = () => {
if (props.isDisabled) return;
// 当是输入框且输入框的内容不为空时候,是发送文本,否则就是切换
if (!isInputVisible.value && inputText.value !== '') {
console.log('send_msg');
sendMsg();
} else {
isInputVisible.value = !isInputVisible.value;
}
};
// 禁用时点击给的提示
const disabled_click = () => {
if (props.isDisabled) {
console.log('禁用时点击给的提示', props.disabledText);
common.msg(props.disabledText);
}
};
// 发送文字
const sendMsg = () => {
if (!isInputVisible.value) {
emit('submitAnswer', inputText.value, changeBtn);
} else {
emit('submitAnswer', textAreaText.value, changeBtn);
closePopup();
}
};
// 发送原语音
const originalSend = () => {
console.log('发送原语音', { ossKey: ossKey.value, text: allText.value, recordId: recordId.value });
emit('submitVoice', { ossKey: ossKey.value, text: allText.value, recordId: recordId.value });
closePopup();
};
setupKeyboardHeightListener((height: number) => {
const _height = Math.max(height - 100, 0);
pageBoxMarginBottom.value = `${_height}px`;
});
const startRecordingTimer = (status: boolean) => {
const clearRecordingTimer = () => {
if (durationTimer) {
clearTimeout(durationTimer);
durationTimer = null; // 重置定时器变量,避免内存泄漏
console.log('录音时长定时器已清除');
}
};
clearRecordingTimer();
if (!status) return;
const maxDuration = props.maxRecordDuration;
durationTimer = setTimeout(() => {
console.log(`录音达到最大时长 ${maxDuration} 秒,自动关闭`);
// 执行关闭录音的逻辑
stopRecord();
clearRecordingTimer();
}, maxDuration);
};
// 外面调用放弃这次
const give_up = () => {
console.log('调用结束语音回答');
stopRecord();
};
// 外部调用,清理输入框数据
const clearInputText = (text = '') => {
const old_text = inputText.value;
inputText.value = text;
return old_text;
};
defineExpose({ clearInputText, give_up });
</script>
<style lang="scss" scoped>
@import './style/index.scss';
@import './style/popup.scss';
.white-bg-box {
height: 365rpx;
background-color: #fff;
overflow: hidden;
border-radius: 32rpx 32rpx 0 0;
padding: 58rpx 36rpx;
.box-title {
font-size: 32rpx;
font-weight: bold;
line-height: 46rpx;
margin-bottom: 46rpx;
}
.box-btns {
height: 46rpx;
line-height: 46rpx;
display: flex;
justify-content: space-between;
color: #999;
.is-active {
color: #06f;
}
}
}
::v-deep .uv-input__content__field-wrapper__field {
padding-right: 60rpx;
}
</style>