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

427 lines
14 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="!isTextOnlyMode && 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="true"
placeholderStyle="color:#999999;font-size:26rpx" placeholder="请输入文字"
:disabled="props.isDisabled" @click="disabled_click"></input>
</view>
<view class="talk-right-icon-div" v-if="!isTextOnlyMode && !isVoiceOnlyMode">
<image class="talk-right-icon" :src="talkRightIcon" mode="widthFix" @click="changeBtn"></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="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="iconRecognizeErrorSvg()" mode="aspectFill" class="img"></image>
<view class="">
未识别到文字
</view>
</view>
</view>
</view>
</view>
<!-- 第一形态两侧按钮 -->
<view class="page-box" :style="{marginBottom:pageBoxMarginBottom}">
<view class="page-1" :class="[status, props.mode]">
<view class="btns-box">
<!-- 左侧取消按钮 -->
<image class="btn-box btn-box-1" :src="isOutStatus == '02' ? cancelActiveBtn : cancelBtn"
mode="heightFix" @click="closePopup()"></image>
<!-- 右侧转文字按钮 -->
<image class="btn-box btn-box-2" :src="isOutStatus == '03' ? translateActiveBtn : translateBtn"
mode="heightFix"></image>
<image class="btn-box-3" :src="isOutStatus == '02'? closeActiveBtn : 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]" >
<!-- 左侧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 { cancelBtn, cancelActiveBtn, translateBtn, translateActiveBtn, voiceWhiteBtn,closeBtn,closeActiveBtn, icKeyboardSvg, iconMicSvg, iconSendSvg, iconRecognizeErrorSvg } from './touch-btn.images';
import { StatusType, VoiceToTextSubStatus,ErrorCode } 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 from '@/common/common';
import {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({
// mode控制功能模式
mode: {
type: String,
default: 'voice-only',
validator: (value : string) => {
return ['voice-only', 'text-only', 'both'].includes(value);
}
},
isDisabled: {
default: false,
type: Boolean
},
isLoading: {
default: false,
type: Boolean
},
loadingText: {
default: '发送回答中,请稍后再试!',
type: String
},
// 禁用时候点击反馈的文本
disabledText: {
default: '请稍后再试!',
type: String
},
canAnswer: {
default: true,
type: Boolean
},
canAnswerText: {
default: '当前状态不能发送内容!',
type: String
},
maxlength: {
type: Number,
default: 500
},
minRecordDuration: {
// 最小录音间隔
type: Number,
default: 1000
}
});
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' && 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);
// 键盘还是语音
const isInputVisible = ref(props.mode !== 'text-only'); // true=显示录音按钮,false=显示输入框
const recordStartTime = ref(0); // 录音开始时间戳
const pressTimer = ref<number | null>(null);
// 计算属性:判断是否仅语音/仅文字模式(简化模板逻辑)
const isVoiceOnlyMode = computed(() => props.mode === 'voice-only');
const isTextOnlyMode = computed(() => props.mode === 'text-only');
const talkRightIcon = computed(() => {
if (isInputVisible.value) return icKeyboardSvg();
if (inputText.value) return iconSendSvg();
return iconMicSvg();
});
// 计算是否超出按下盒子位置01代表下方盒子,02代表左上03代表右上
const isOutStatus = computed(() => {
const { y: touchY, x: touchX } = touchPos;
if (touchY >= bottomAreaTopBoundary) return '01';
// 仅语音模式只能返回02,就是取消
if(props.mode === '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;
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 = '';
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) {
if(controlBody?.txt === '') { //说明没有识别到文字
voice_to_text_status.value = 'failed'
// todo
} else { // 识别成功
voice_to_text_status.value = 'success'
const ossKey = controlBody?.ossKey;
const text = controlBody?.txt;
// todo
console.log('ossKey', ossKey);
emit('submitVoice', {ossKey,text})
}
}
}
} catch (e) {
console.log('后端发来的信息有问题');
}
});
}).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) {
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; // 未处于录音状态
if (isOutStatus.value === '03') {
status.value = 'voice_to_text' //主状态设置为转文字
voice_to_text_status.value = 'init'
} else if(isOutStatus.value === '01') {
status.value = 'voice_send_ing' //主状态设置为语音发送中
}
if(['01', '03'].includes(isOutStatus.value)) { // 向后端发送抬手动作
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.value !== '02') { // 取消的时候,不校验最低时间
// 核心:计算录音总时长(毫秒转秒,保留1位小数)
if (recordDurationMs < 600) {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 600 - recordDurationMs)
})
}
}
isRecording.value = false;
if (isOutStatus.value === '02' || errrStatus === 'networkError') { // 取消的时候自己主动关闭websocket,其他的时候需要等待后端的消息关闭
ws.value && ws.value.close({
code: 1000,
reason: '挂断',
complete: () => {
console.log('挂断Ws');
wsStatus.value = false;
}
});
}
stopAudioRecord(); // 关掉录音
if (isOutStatus.value !== '03' || errrStatus === 'networkError') { // 转文字时候不关遮罩层,网络错误必须关遮罩层
popupRef.value.close();
// 关掉弹窗后的提示
if(errrStatus === 'networkError') {
return showErrorTip(ErrorCode.NetworkError)
} else if (isOutStatus.value !== '02' && recordDurationMs <= 1200) { // 取消的时候,不校验最低时间
return showErrorTip(ErrorCode.ShortRecord)
}
}
if(isOutStatus.value === '01' && status.value == 'voice_send_ing') {
emit('sendLoading', {recordId:recordId.value})
}
};
// 发送文本
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 = () => {
if (props.isDisabled) return;
isInputVisible.value = !isInputVisible.value;
inputText.value = '';
};
// 禁用时点击给的提示
const disabled_click = () => {
console.log('禁用时点击给的提示');
if (props.isDisabled) common.msg(props.disabledText);
};
const send_msg = () => {
console.log('点击发送');
}
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>