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

538 lines
17 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="255"
@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">松开发送</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="testFun()">
<text class="btn-text">发送原语音</text>
</view>
<!-- 右侧发送按钮椭圆 -->
<view class="send-btn" @click="send_msg()">
<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 } 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
},
adjustPosition:{
default: true,
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 touchPos = reactive({ x: 0, y: 0 });
// 是否录音中
const isRecording = ref(false);
let voiceTimer = null; // 兜底关闭的定时器全局唯一
// 键盘还是语音
const isInputVisible = ref(true); // true=显示录音按钮,false=显示输入框
const recordStartTime = ref(0); // 录音开始时间戳
const pressTimer = ref<number | null>(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.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 = 'voice_recording';
voice_to_text_status.value = '';
tempText.value = '';
allText.value = '';
console.log('都清空了');
initWebSocket(wsUrl)
.then((socketTask) => {
ws.value = socketTask;
wsStatus.value = true;
console.log('WS连接成功');
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);
// 如果不等于init说明已经超时,被超时函数处理完了,这里即使返回也不做处理
if (status.value === 'voice_to_text' && voice_to_text_status.value !== 'init') return;
if (status.value === 'send_over') return;
if (voiceTimer) clearTimeout(voiceTimer);
// 设置识别结果
if (controlBody?.txt === '') {
//说明没有识别到文字
voice_to_text_status.value = 'failed';
} else {
voice_to_text_status.value = 'success';
}
// status.value = 'send_over'; // 设置成发送完成
// 取值出来
const ossKey = controlBody?.ossKey || '';
let text = '';
if (typeof controlBody?.txt === 'string') {
text = controlBody.txt.trim();
}
const _isOutStatus =
workMode.value === 'text-only' && isOutStatus.value === '01' ? '03' : isOutStatus.value;
// 只有模式为01的时候才发送出去
if (_isOutStatus === '01') {
emit('submitVoice', { ossKey, text, recordId: recordId.value });
}
}
}
} catch (e) {
console.log('后端发来的信息有问题', e);
}
});
})
.catch(() => {
stopRecord('networkError');
console.log('网络出现问题');
});
pressTimer.value = setTimeout(() => {
vibrateLight(); // 触感震动
// 重设按下位置
const changedTouche = obj.touches[0];
touchPos.x = changedTouche.clientX;
touchPos.y = changedTouche.clientY;
// 记录开始时间戳(毫秒)
recordStartTime.value = Date.now();
// 弹出录音界面
popupRef.value.open();
startAudioRecord({
onFrame: (pcmData: any) => {
if (wsStatus.value && ws.value) {
console.log('pcmData', pcmData.length);
ws.value.send({ data: ProtocolCodec.pack(MessageType.AUDIO_DATA, pcmData) });
}
},
onStart: (res: any) => {
console.log('启动', res);
recordId.value = res.recordId ?? '';
},
onStop: () => {
console.log('关闭');
},
onError: ({ errCode, errMsg }) => {
console.log('onError', errMsg);
if (errCode === 9010001) {
console.log(errMsg);
}
}
});
}, 120);
};
// 结束录音
const closePopup = () => {
popupRef.value.close();
};
const stopRecord = async (errrStatus = '') => {
clearTimeout(pressTimer.value);
if (!isRecording.value) return; // 未处于录音状态
const _isOutStatus = workMode.value === 'text-only' && isOutStatus.value === '01' ? '03' : isOutStatus.value;
console.log('_isOutStatus', _isOutStatus);
// 拖动到转文字按钮上抬手或者仅文字模式的发送也强行算他转文字
if (_isOutStatus === '03') {
status.value = 'voice_to_text'; //主状态设置为转文字
voice_to_text_status.value = 'init';
if (voiceTimer) clearTimeout(voiceTimer);
voiceTimer = setTimeout(() => {
voiceTimer = null;
if (voice_to_text_status.value !== 'init') return;
voice_to_text_status.value = 'networkError';
emit('submitVoice', { ossKey: '', text: '', recordId: recordId.value });
}, 5000);
} else if (_isOutStatus === '01') {
status.value = 'voice_send_ing'; //主状态设置为语音发送中
}
if (['01', '03'].includes(_isOutStatus)) {
// 向后端发送抬手动作
ws.value &&
ws.value.send({
data: ProtocolCodec.pack(MessageType.CONTROL_CMD, { type: ControlCode.FRONT_TO_SERVER_OVER })
});
}
const recordDurationMs = Date.now() - recordStartTime.value;
if (_isOutStatus !== '02') {
// 取消的时候,不校验最低时间
// 核心:计算录音总时长(毫秒转秒,保留1位小数)
if (recordDurationMs < 600) {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 600 - recordDurationMs);
});
}
}
isRecording.value = false;
if (_isOutStatus === '02' || errrStatus === 'networkError') {
// 取消的时候自己主动关闭websocket,其他的时候需要等待后端的消息关闭
ws.value &&
ws.value.close({
code: 1000,
reason: '挂断',
complete: () => {
console.log('挂断Ws');
wsStatus.value = false;
}
});
}
stopAudioRecord(); // 关掉录音
if (_isOutStatus !== '03' || errrStatus === 'networkError') {
// 转文字时候不关遮罩层,网络错误必须关遮罩层
popupRef.value.close();
// 关掉弹窗后的提示
if (errrStatus === 'networkError') {
return showErrorTip(ErrorCode.NetworkError);
} else if (_isOutStatus !== '02' && recordDurationMs <= 1200) {
// 取消的时候,不校验最低时间
return showErrorTip(ErrorCode.ShortRecord);
}
}
if (_isOutStatus === '01' && status.value == 'voice_send_ing') {
emit('sendLoading', { recordId: recordId.value });
// 如果socket长时间没有返回,这里兜底一下
if (voiceTimer) clearTimeout(voiceTimer);
voiceTimer = setTimeout(() => {
voiceTimer = null;
if (workMode.value === 'voice-only') {
if (status.value === 'send_over') return;
status.value = 'send_over'; // 恢复成默认状态
emit('submitVoice', { ossKey: '', text: '', recordId: recordId.value });
}
}, 5000);
}
};
// 发送文本
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');
send_msg();
} else {
isInputVisible.value = !isInputVisible.value;
}
};
// 禁用时点击给的提示
const disabled_click = () => {
console.log('禁用时点击给的提示');
if (props.isDisabled) common.msg(props.disabledText);
};
const send_msg = () => {
if (!isInputVisible.value) {
emit('submitAnswer', inputText.value, changeBtn);
} else {
emit('submitAnswer', textAreaText.value, changeBtn);
closePopup();
}
};
setupKeyboardHeightListener((height: number) => {
const _height = Math.max(height - 100, 0);
pageBoxMarginBottom.value = `${_height}px`;
});
// 外面调用放弃这次
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>