Files
tra-app/src/pages/sparring/talk.vue
T
2025-12-15 09:33:06 +08:00

473 lines
11 KiB
Vue

<template>
<view>
<image-preview :src="dataInfo.ossAddr" class="show-box-avatar"></image-preview>
<view class="show-box" :class="{ isVideo: isVideoStyle }">
<nav-bar :is_seat="true"></nav-bar>
<view class="switch-div" v-if="isVideoStyle">
<!-- <view class="switch-text">场景提示</view> -->
<!-- <switch class="switch" :checked="!sceneDescShow" @click="switchChange" /> -->
<view class="title-time" v-if="isRecording">{{ formattedTime }}</view>
<view class="title-tip" v-if="!isRecording">正在接通中</view>
</view>
<view class="fl1-div-1"></view>
<view class="role-info">
<image-preview :src="dataInfo.ossAddr" class="role-avatar" @click="test()"></image-preview>
<view class="role-name">{{ dataInfo.chaName }}</view>
<view class="title-time" v-if="isRecording">{{ formattedTime }}</view>
<view class="title-tip" v-if="!isRecording">正在接通中</view>
</view>
<view class="fl1"></view>
<view class="body-scene" :class="{ show: sceneDescShow }">
{{ dataInfo.sceneDesc }}
</view>
<view class="close-btn" @click="closeTalking"></view>
</view>
<talk-microphone ref="talkMicrophoneRef"></talk-microphone>
<talk-speaker ref="talkSpeakerRef" @playOver="playOver"></talk-speaker>
<talk-camera ref="talkCameraRef" v-if="isVideo"></talk-camera>
</view>
</template>
<script setup>
import {
ref,
reactive,
onMounted,
onUnmounted,
computed,
getCurrentInstance,
nextTick,
toRaw
} from 'vue';
import {
onShow,
onLoad,
onUnload,
onBackPress,
onReady
} from '@dcloudio/uni-app';
import {
ProtocolCodec,
MessageType,
CompressionType,
ControlCommand
} from './js/ProtocolCodec';
import common from '@/common/common';
import talkMicrophone from './components/talk-microphone.vue';
import talkSpeaker from './components/talk-speaker.vue';
import talkCamera from './components/talk-camera';
import useWebSocket from './js/useWebSocket';
const talkMicrophoneRef = ref(null);
const talkSpeakerRef = ref(null);
const talkCameraRef = ref(null);
const {
initConnection,
close,
send,
isRecording
} = useWebSocket();
const isVideo = ref(false);
const isVideoStyle = computed(() => dataInfo.traInterTyp === '03');
const dataInfo = reactive({
traId: '',
chaId: '',
traType: '',
traInterTyp: '',
execId: '',
sceneDesc: '',
preview: false, // 是否是预览
traInterTypDesc: '', // 类型的文字提示,例如 语音通话
ossAddr: '', //用户头像
chaName: '' //用户名称
});
const sceneDescShow = ref(false);
const switchChange = () => {
sceneDescShow.value = !sceneDescShow.value;
};
// 关闭通话
const closeTalking = async () => {
// todo 防抖
common.show('', '挂断' + (isVideoStyle.value ? '视频' : '电话') + '将结束训练,是否确认?').then((res) => {
if (res) {
destroy(); // 走关闭销毁流程
}
});
};
const test = () => {
isVideo.value && talkCameraRef.value.switchCamera();
};
// 关闭销毁流程,逐层清理
const destroy = (type = 0) => {
if (type <= 0) {
isVideo.value && talkCameraRef.value.stopPreview();
talkMicrophoneRef.value.stop();
talkSpeakerRef.value.close();
}
if (type <= 1) {
close();
}
if (type <= 2) {
nextTick(() => {
setTimeout(() => {
if (dataInfo.execId) {
common.redirectTo(
'/pages/sparring/result?isOver=1&traId=' +
dataInfo.traId +
'&execId=' +
dataInfo.execId +
'&type=' +
dataInfo.traType +
'&isPreview=' +
(dataInfo.preview ? '1' : '')
);
} else {
common.navigateBack()
}
}, 300);
});
}
};
// 未接通时候报错处理,仅返回和少量销毁
const noConnectDestroy = () => {
console.log('未接通');
setTimeout(() => {
common.show('', '当前服务器繁忙,请稍后在试!', false).then((res) => {
if (res) common.navigateBack();
});
}, 500);
};
const onCurrentDecibels = (res) => {
console.log('当前分贝', res);
};
// 定时器实例
let timer = null;
// 2. 格式化函数:总秒数 → mm:ss(分钟无上限,不进位小时)
const formatTime = (totalSeconds) => {
if (totalSeconds <= 0) return '00:00'; // 过期显示0:00
const minutes = Math.floor(totalSeconds / 60)
.toString()
.padStart(2, '0'); // 分钟无上限
const seconds = totalSeconds % 60; // 秒数
return `${minutes}:${seconds.toString().padStart(2, '0')}`; // 秒补零
};
const targetTimestamp = ref(0);
// 计算属性:实时格式化剩余时间
const formattedTime = ref('00:00');
// 3. 更新剩余时间的核心方法
const updateRemaining = () => {
const now = Date.now();
const diff = now - targetTimestamp.value;
const seconds = Math.max(0, Math.floor(diff / 1000));
formattedTime.value = formatTime(seconds);
};
const onStartRecord = (body) => {
dataInfo.execId = body.execId;
targetTimestamp.value = Date.now();
// 当ws连接成功时候
console.log('连接成功开始' + dataInfo.traInterTypDesc);
isRecording.value = true;
isVideo.value && talkCameraRef.value.startPreview();
if (timer) clearInterval(timer); // 清除旧的定时器,避免多个定时器叠加
timer = setInterval(updateRemaining, 400);
// 立即执行一次
updateRemaining();
talkMicrophoneRef.value.start({
sampleRate: 16000,
frameSize: 1024,
gain: 1.0,
// 音频数据的帧接口回调,直接转码后接入到websocket的发送函数
onFrameRecorded: ({
isLastFrame,
frameBuffer
}) => send(ProtocolCodec.pack(MessageType.AUDIO_DATA, frameBuffer))
});
};
const playOver = (id) => {
console.log('播放完毕talk通知后端', id);
if (id) {
setTimeout(() => {
send(
ProtocolCodec.pack(MessageType.CONTROL_CMD, {
type: 2,
lock: id
})
);
}, 300);
}
};
const onError = (error) => {
// websocket错误处理
console.log('websocket错误处理', error.code);
common.show('', '系统或网络异常,通话结束!', false).then((res) => {
if (res) {
destroy();
}
});
};
const onClose = (error) => {
// websocket错误处理
console.log('websocketonClose关闭', error);
if (error?.code === 1002) {
// 在后端服务失败的时候连接不上
if (dataInfo.execId !== '') {
// 有execId说明通话过程中
destroy();
} else {
// 否则是未接通
noConnectDestroy();
}
} else if (error?.code === 1001) {
// 正常运行中后端掉线了
// onError({code:1001}); //处理逻辑和报错一样,直接调用报错方法
} else if (error?.code === 1011) {
// 服务器错误
onError({
code: 1011
}); //处理逻辑和报错一样,直接调用报错方法
} else if (error?.code === 1000) {
// 用户主动挂断
} else if (error?.code === 4001) {
// 自定义异常挂断,参数错误
noConnectDestroy();
}
};
onLoad((e) => {
dataInfo.traId = e.traId ?? '';
dataInfo.chaId = e.chaId ?? '';
dataInfo.traType = '02'; // 陪练类型 01-练习 02-考试
dataInfo.traInterTyp = e.traInterTyp ?? '02'; // 陪练交互类型 01 对话 02 语音 03 视频
// dataInfo.traInterType = '02'; // 陪练交互类型 01 对话 02 语音 03 视频
//#ifdef APP-PLUS
// 只有app才能打开摄像头
isVideo.value = dataInfo.traInterTyp === '03';
//#endif
dataInfo.preview = e.isPreview === '1'; // 是否是预览
const pageData = common.getPageCache('tipMsgsceneDesc');
dataInfo.ossAddr = pageData.ossAddr;
dataInfo.chaName = pageData.chaName;
dataInfo.traInterTypDesc = pageData.traInterTypDesc;
});
onMounted(() => {
initConnection({
authParams: {
traId: dataInfo.traId,
chaId: dataInfo.chaId,
traType: dataInfo.traType,
traInterTyp: dataInfo.traInterTyp,
preview: dataInfo.preview
},
onStartRecord: onStartRecord,
playPcmCallback: (res) => talkSpeakerRef.value.playPCM(res),
setPlayNumber: (id) => talkSpeakerRef.value.setPlayNumber(id),
onError,
onClose
});
});
onReady(() => {
if (isVideo.value) {
const instance = getCurrentInstance();
const componentProxy = instance.ctx;
nextTick(() => {
talkCameraRef.value.init(componentProxy);
});
}
});
onUnmounted(() => {
console.log('组件卸载');
if (timer) clearInterval(timer);
});
onBackPress((res) => res.from === 'backbutton');
</script>
<style scoped lang="scss">
.isVideo {
// background-color: red;
.role-info {
display: none;
}
&.show-box {
background: linear-gradient(120deg,
rgba(51, 51, 51, 0.2) 0%,
rgba(102, 102, 102, 0.2) 50%,
rgba(51, 51, 51, 0.2) 100%);
backdrop-filter: blur(0px);
}
}
.title-time,
.title-tip {
text-align: center;
color: #fff;
line-height: 58rpx;
font-size: 38rpx;
margin: 0 auto;
}
.role-name {
margin-bottom: 26rpx;
// position: relative;
// width: 100vw;
text-align: center;
color: #fff;
line-height: 58rpx;
font-size: 38rpx;
}
.title-tip {
padding-right: 20rpx;
position: relative;
&::after {
content: '';
position: absolute;
animation: loading 2.5s infinite;
}
@keyframes loading {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
100% {
content: '';
}
}
}
.switch-div {
display: flex;
justify-content: flex-end;
align-items: center;
margin-right: 20rpx;
margin-top: 10rpx;
width: 100%;
.switch-text {
font-weight: 400;
font-size: 28rpx;
color: #ffffff;
}
.switch {
transform: scale(0.7) rotate(180deg);
:deep(.uni-switch-input) {
&.uni-switch-input-checked {
border-color: #9eacbf !important;
background-color: #9eacbf !important;
}
&:after {
top: 5px;
left: 5px;
width: 20px;
height: 20px;
}
&:before {
background-color: #62a1ff !important;
border-color: #62a1ff !important;
}
&:not(.uni-switch-input-checked) {
background-color: #62a1ff !important;
border-color: #62a1ff !important;
}
}
}
}
.fl1-div-1 {
min-height: 120rpx;
max-height: 240rpx;
}
.show-box {
background: linear-gradient(120deg, #333333cc 0%, #666666cc 50%, #333333cc 100%);
backdrop-filter: blur(10px);
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
}
.show-box-avatar {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
z-index: 900;
}
.role-info {
position: relative;
width: 100vw;
padding-top: 210rpx;
.role-avatar {
width: 185rpx;
height: 185rpx;
border-radius: 50%;
overflow: hidden;
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 0;
}
}
.body-scene {
overflow: auto;
width: calc(100% - 92rpx);
// margin: 46rpx;
padding: 26rpx;
color: rgba(255, 255, 255, 0.8);
border: 4rpx dashed rgba(255, 255, 255, 0.3);
border-radius: 16rpx;
opacity: 0;
transition: opacity 0.5s ease;
&.show {
opacity: 1;
}
}
.close-btn {
width: 118rpx;
height: 118rpx;
background-image: url(@/static/images/sparring/close-talk.png);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
border-radius: 50%;
margin-top: 46rpx;
margin-bottom: 112rpx;
flex-shrink: 0;
}
</style>