解决了大量遗留bug

This commit is contained in:
田岩
2025-12-17 10:22:32 +08:00
parent 9c7ae6cf11
commit 2185e00fa7
30 changed files with 3058 additions and 888 deletions
+4 -3
View File
@@ -6,7 +6,7 @@ ENV = 'development'
# VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001'
#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002'
VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001'
# VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002'
@@ -14,7 +14,7 @@ ENV = 'development'
# VITE_APP_BASE_API_Url = 'http://aitscdn.jlbank.com.cn:7001'
# VITE_APP_BASE_API_Url = 'http://192.168.108.129'
# dev
VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001'
#VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001'
# sit
#VITE_APP_BASE_API_Url = 'http://25.18.122.91:9786'
@@ -44,6 +44,7 @@ VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001'
# VITE_APP_BASE_H5_API_Url_TRASTUDY = 'http://25.64.16.130:9602'
# VITE_APP_BASE_H5_API_Url_TRAASK = 'http://25.64.16.144:9605'
#VITE_APP_BASE_H5_API_Url_TRAEXAM = 'http://25.64.16.139:9604'
# VITE_APP_BASE_H5_API_Url_TRAEXAM = 'http://25.64.16.154:9604'
+2 -1
View File
@@ -2,7 +2,8 @@
"version" : "1.0",
"configurations" : [
{
"playground" : "custom",
"customPlaygroundType" : "device",
"playground" : "standard",
"type" : "uni-app:app-android"
},
{
+4 -2
View File
@@ -2,6 +2,7 @@
"name": "tra-app",
"version": "0.0.1",
"description": "",
"overrides": {"rollup":"4.14.3"},
"scripts": {
"dev:h5": "uni",
"dev:rd": "uni -p h5 --mode development.rd",
@@ -39,11 +40,12 @@
"dompurify": "^3.3.0",
"markdown-it": "^14.1.0",
"pinia": "^2.3.1",
"pnpm": "^10.25.0",
"terser": "^5.42.0",
"text-encoding": "^0.7.0",
"uuid": "^11.1.0",
"vue": "^3.5.11",
"vue-i18n": "^9.1.9",
"text-encoding": "^0.7.0"
"vue-i18n": "^9.1.9"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
+2 -2
View File
@@ -21,7 +21,7 @@
<style lang="scss">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
// @import "@/uni_modules/uview-ui/index.scss";
// #ifndef APP-PLUS-NVUE
@font-face {
font-family: 'PingFangSC';
src: url('@/static/font/PingFang-SC.ttf') format('truetype');
@@ -68,7 +68,7 @@
.max_page {
min-height: 100vh;
}
// #endif
.mr-10 {
margin-right: 10rpx;
+5 -1
View File
@@ -61,7 +61,11 @@
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>"
]
],
"hardwareAccelerated": true, // 开启硬件加速
"livePusher": {
"renderMode": "texture" // 核心:纹理渲染模式,支持CSS裁切
}
},
/* ios */
"ios" : {
+10
View File
@@ -522,6 +522,16 @@
}
}
},
{
"path": "pages/sparring/talkVideo",
"style": {
"navigationBarTitleText": "AI陪练视频通话",
"disableSwipeBack": true,
"app-plus": {
"titleNView": false
}
}
},
{
"path": "pages/sparring/result",
"style": {
+4 -3
View File
@@ -83,12 +83,13 @@
<image class="img" src="@/static/images/competition/matching-success-right.png" mode="widthFix"></image>
<view class="information-div-right">
<view class="avatar-img-div">
<image class="img" v-if="data_info.pkIsAnony === 'Y'" :src="defaultAnonymousAvatar" mode="aspectFill"></image>
<image-preview
v-if="data_info.pkIsAnony === 'N'"
v-else-if="data_info.pkImgAddr"
class="img"
:src="data_info.pkImgAddr"
></image-preview>
<image v-else class="img" :src="defaultAnonymousAvatar" mode="aspectFill"></image>
<image class="img" v-else :src="defaultAvatar"></image>
</view>
<view class="name">
{{ data_info.pkIsAnony === 'N' ? data_info.pkUserName : formatName(data_info.pkUserName)}}
@@ -125,7 +126,7 @@
import { MatchTimeGenerator, formatTime } from './js/matching';
import { queryTraCompetitionInfoByPkUser } from '@/api/competition';
import { onLoad } from '@dcloudio/uni-app';
import { defaultAnonymousAvatar } from '@/enum';
import { defaultAnonymousAvatar, defaultAvatar } from '@/enum';
import { pkAnonymousClose, pkAnonymousOpen, pkRankingList, pkRules, switchIcon, optionErrorIcon } from '@/common/imgSvg';
import { updateTraUserAnony } from '@/api/user';
import selectionInstitutions from '@/components/selection-institutions/index.vue';
+11 -4
View File
@@ -26,8 +26,10 @@
</view>
<view class="personnel-div">
<view class="avatar-div">
<image-preview v-if="pkData.pkIsAnony === 'N'" class="img" :src="pkData.pkImgAddr"></image-preview>
<image v-else class="img" :src="defaultAnonymousAvatar" mode="aspectFill"></image>
<image v-if="pkData.pkIsAnony === 'Y'" class="img" :src="defaultAnonymousAvatar" mode="aspectFill"></image>
<image-preview v-else-if="pkData.pkImgAddr" class="img" :src="pkData.pkImgAddr"></image-preview>
<image class="img" v-else :src="defaultAvatar"></image>
</view>
<view class="name-div">{{ pkData.pkIsAnony === 'N' ? pkData.pkUserName : formatName(pkData.pkUserName) }}</view>
</view>
@@ -110,7 +112,7 @@
<script setup>
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from 'vue';
import { onLoad, onHide, onShow, onUnload, onBackPress, onReady } from '@dcloudio/uni-app';
import { defaultAnonymousAvatar, styleButton } from '@/enum';
import { defaultAnonymousAvatar, styleButton,defaultAvatar } from '@/enum';
import Feedback from '@/pages/examination/components/feedback.vue';
import Dialog from '@/pages/examination/components/dialog.vue';
import Subject from '@/components/examination/subject.vue';
@@ -459,7 +461,12 @@
clearInterval(practiceTimeTimer);
}
})
onBackPress((res) => res.from === 'backbutton');
onBackPress((res) => {
if(res.from === 'backbutton') {
exit_practice()
return true;
}
});
</script>
<style scoped lang="scss">
+5 -3
View File
@@ -60,8 +60,10 @@
</view>
<view class="pk-msg-right-div" :class="action">
<view class="avatar-div">
<image-preview v-if="pkData.pkIsAnony === 'N'" class="img" :src="pkData.imgPk"></image-preview>
<image v-else class="img" :src="defaultAnonymousAvatar" mode="aspectFill"></image>
<image v-if="pkData.pkIsAnony === 'Y'" class="img" :src="defaultAnonymousAvatar" mode="aspectFill"></image>
<image-preview v-else-if="pkData.imgPk" class="img" :src="pkData.imgPk"></image-preview>
<image class="img" v-else :src="defaultAvatar"></image>
</view>
<view class="name-div">
{{ pkData.pkIsAnony === 'N' ? pkData.userNamePk : formatName(pkData.userNamePk) }}
@@ -109,7 +111,7 @@
import { MatchTimeGenerator, formatTime } from './js/matching';
import { queryCompetitionAnswerResult } from '@/api/competition';
import { onLoad } from '@dcloudio/uni-app';
import { defaultAnonymousAvatar } from '@/enum';
import { defaultAnonymousAvatar,defaultAvatar } from '@/enum';
import { pkAnonymousClose, pkAnonymousOpen, pkRankingList, pkRules } from '@/common/imgSvg';
import { queryTraUserAnonyByUserId, updateTraUserAnony } from '@/api/user';
import { formatSecondsToMS } from './js/tool';
+7 -1
View File
@@ -672,7 +672,13 @@
}
});
};
onBackPress((res) => res.from === 'backbutton');
onBackPress((res) => {
if(res.from === 'backbutton') {
hand_in_paper_button_click()
return true;
}
});
</script>
<style scoped lang="scss">
+7 -1
View File
@@ -465,7 +465,13 @@
onUnload(() => {
stopTimer();
});
onBackPress((res) => res.from === 'backbutton');
onBackPress((res) => {
if(res.from === 'backbutton') {
exit_practice()
return true;
}
});
</script>
<style scoped lang="scss">
@@ -0,0 +1,324 @@
<template>
<view class="live-container" :style="{ top: `${topVal}rpx`, left: `${leftVal}rpx` }">
<cover-view class="pusher-container" @click.stop>
<live-pusher
id="livePusher"
class="livePusher"
:muted="true"
:enable-camera="true"
:auto-focus="true"
:beauty="0"
:whiteness="0"
aspect="9:16"
mode="SD"
></live-pusher>
</cover-view>
<view
class="drag-controller"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<!-- 完全透明,只用于接收触摸事件 -->
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 响应式数据
const topVal = ref(150) // 初始顶部位置 (rpx)
const leftVal = ref(0) // 初始左侧位置 (rpx)
const livePusherContext = ref(null)
// 触摸相关数据
const startX = ref(0) // 触摸起始X坐标 (px)
const startY = ref(0) // 触摸起始Y坐标 (px)
const startLeft = ref(0) // 触摸起始时的left值 (rpx)
const startTop = ref(0) // 触摸起始时的top值 (rpx)
const isAnimating = ref(false) // 新增:动画锁,防止多次触发
// 系统信息
const systemInfo = uni.getSystemInfoSync()
const screenWidthPx = systemInfo.windowWidth // 屏幕宽度(px
const screenHeightPx = systemInfo.windowHeight // 屏幕高度(px
const rpxRatio = 750 / screenWidthPx // rpx 转换比例
const SCREEN_WIDTH_RPX = 750 // 新增:统一屏幕宽度(rpx),与设计稿一致
// 容器尺寸 (rpx)
const CONTAINER_WIDTH = 270
const CONTAINER_HEIGHT = 480
console.log('系统信息:', {
screenWidthPx,
screenHeightPx,
rpxRatio,
CONTAINER_WIDTH,
CONTAINER_HEIGHT,
SCREEN_WIDTH_RPX
})
// 初始化
const init = (componentProxy) => {
livePusherContext.value = uni.createLivePusherContext('livePusher', componentProxy)
}
// 切换摄像头
const switchCamera = () => {
if (!livePusherContext.value) return
livePusherContext.value.switchCamera({
success: (res) => {
console.log('livePusher.switchCamera:', JSON.stringify(res))
},
fail: (err) => {
console.error('切换摄像头失败:', err)
}
})
}
// 触摸开始事件 - 增强错误处理
const handleTouchStart = (e) => {
console.log('=== 触摸开始 ===')
// 新增:动画中不响应新的触摸开始
if (isAnimating.value) {
console.log('动画中,忽略触摸开始')
return
}
console.log('事件对象:', e)
console.log('触摸点数组:', e.touches)
try {
if (!e.touches || e.touches.length === 0) {
console.error('没有检测到触摸点')
return
}
const touch = e.touches[0]
console.log('第一个触摸点:', touch)
// 安全地获取触摸坐标
const clientX = touch.clientX || touch.pageX || 0
const clientY = touch.clientY || touch.pageY || 0
startX.value = clientX
startY.value = clientY
// 记录当前容器的位置
startLeft.value = leftVal.value
startTop.value = topVal.value
console.log('记录的起始位置:', {
startX: startX.value,
startY: startY.value,
startLeft: startLeft.value,
startTop: startTop.value
})
} catch (error) {
console.error('触摸开始事件处理出错:', error)
}
}
// 触摸移动事件 - 修复计算逻辑
const handleTouchMove = (e) => {
console.log('=== 触摸移动 ===')
// 防止事件穿透
if (e.stopPropagation) e.stopPropagation()
if (e.preventDefault) e.preventDefault()
// 新增:动画中不响应触摸移动
if (isAnimating.value) return
try {
if (!e.touches || e.touches.length === 0) {
console.log('触摸移动:没有触摸点')
return
}
const touch = e.touches[0]
console.log('移动时的触摸点:', touch)
// 安全地获取当前触摸坐标
const currentX = touch.clientX || touch.pageX || startX.value
const currentY = touch.clientY || touch.pageY || startY.value
console.log('坐标比较:', {
startX: startX.value,
currentX: currentX,
startY: startY.value,
currentY: currentY
})
// 计算偏移量 (px)
const deltaX = currentX - startX.value
const deltaY = currentY - startY.value
console.log('偏移量计算:', { deltaX, deltaY })
// 检查偏移量是否有效
if (isNaN(deltaX) || isNaN(deltaY)) {
console.error('计算出 NaN 偏移量:', { deltaX, deltaY })
return
}
// 将偏移量转换为 rpx 并更新位置
const deltaXInRpx = deltaX * rpxRatio
const deltaYInRpx = deltaY * rpxRatio
const newLeft = startLeft.value + deltaXInRpx
const newTop = startTop.value + deltaYInRpx
// 边界检测 - 修复:使用统一的SCREEN_WIDTH_RPX,而非硬编码750
const maxLeft = SCREEN_WIDTH_RPX - CONTAINER_WIDTH
const screenHeightInRpx = screenHeightPx * rpxRatio
const maxTop = screenHeightInRpx - CONTAINER_HEIGHT
console.log('最终位置计算:', {
deltaX, deltaY,
deltaXInRpx, deltaYInRpx,
newLeft, newTop,
maxLeft, maxTop,
screenHeightInRpx
})
// 确保位置在有效范围内且不是 NaN
if (!isNaN(newLeft) && !isNaN(newTop)) {
leftVal.value = Math.max(0, Math.min(newLeft, maxLeft))
topVal.value = Math.max(0, Math.min(newTop, maxTop))
console.log('更新后的位置:', { left: leftVal.value, top: topVal.value })
} else {
console.error('计算出 NaN 位置:', { newLeft, newTop })
}
} catch (error) {
console.error('触摸移动事件处理出错:', error)
}
}
// 触摸结束事件 - 核心:自动吸附到最近边缘 + 平滑动画
const handleTouchEnd = (e) => {
console.log('=== 触摸结束 ===')
if (isAnimating.value) return
// 1. 计算核心参数
const currentLeft = leftVal.value // 松手时小窗的左偏移
const windowCenterX = currentLeft + CONTAINER_WIDTH / 2 // 小窗中心的X坐标(rpx)
const screenMiddleX = SCREEN_WIDTH_RPX / 2 // 屏幕中线X坐标(rpx
// 2. 判断更近的边缘:中心在左半屏 → 吸附左侧;右半屏 → 吸附右侧
const targetLeft = windowCenterX < screenMiddleX ? 0 : SCREEN_WIDTH_RPX - CONTAINER_WIDTH
const targetTop = topVal.value // Y轴保持松手位置
// 3. 平滑动画过渡
const duration = 140 // 动画时长(ms
const frameCount = 60 // 动画帧数
const stepX = (targetLeft - currentLeft) / frameCount // 每帧X偏移量
const stepY = (targetTop - topVal.value) / frameCount // Y轴无偏移
let currentFrame = 0
isAnimating.value = true
const animationTimer = setInterval(() => {
currentFrame++
// 更新位置
leftVal.value += stepX
topVal.value += stepY
// 动画结束
if (currentFrame >= frameCount) {
clearInterval(animationTimer)
// 强制修正到目标位置(避免浮点误差)
leftVal.value = targetLeft
topVal.value = targetTop
isAnimating.value = false
console.log('吸附完成,最终位置:', { left: leftVal.value, top: topVal.value })
}
}, duration / frameCount)
// 仅重置触摸起始坐标
startX.value = 0
startY.value = 0
}
// 开启预览
const startPreview = () => {
console.log('摄像头开启预览')
if (!livePusherContext.value) return
livePusherContext.value.startPreview({
success: (res) => {
console.log('摄像头开启成功')
},
fail: (err) => {
console.error('开启预览失败:', err)
}
})
}
// 关闭预览
const stopPreview = () => {
console.log('关闭预览')
if (!livePusherContext.value) return
livePusherContext.value.stopPreview({
success: (res) => {
console.log('livePusher.stopPreview:', JSON.stringify(res))
},
fail: (err) => {
console.error('关闭预览失败:', err)
}
})
}
// 组件挂载后的初始化
onMounted(() => {
console.log('组件挂载完成')
// 初始化左侧位置为屏幕右侧
leftVal.value = SCREEN_WIDTH_RPX - CONTAINER_WIDTH // 修复:使用容器实际宽度,而非硬编码280
console.log('初始化位置:', {
screenWidthPx,
rpxRatio,
SCREEN_WIDTH_RPX,
leftVal: leftVal.value
})
})
// 暴露方法给父组件
defineExpose({
init,
switchCamera,
stopPreview,
startPreview
})
</script>
<style scoped>
.live-container {
position: absolute;
overflow: hidden;
border-radius: 20rpx;
width: 270rpx;
height: 480rpx;
background-color: red;
display: flex;
}
.pusher-container {
width: 270rpx;
height: 480rpx;
}
.drag-controller {
width: 100%;
height: 100%;
background-color: rgba(255, 0, 0, 0.1); /* 半透明便于调试 */
position: absolute;
z-index: 999;
}
</style>
@@ -0,0 +1,322 @@
<template>
<view class="live-container" :style="{ top: `${topVal}px`, left: `${leftVal}px` }">
<cover-view class="pusher-container" @click.stop>
<live-pusher
id="livePusher"
class="livePusher"
:muted="true"
:enable-camera="true"
:auto-focus="true"
:beauty="0"
:whiteness="0"
aspect="9:16"
mode="SD"
></live-pusher>
</cover-view>
<view
class="drag-controller"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 响应式数据 - 直接使用px单位
const topVal = ref(40) // 初始顶部位置 (px)
const leftVal = ref(0) // 初始左侧位置 (px)
const livePusherContext = ref(null)
// 触摸相关数据
const startX = ref(0) // 触摸起始X坐标 (px)
const startY = ref(0) // 触摸起始Y坐标 (px)
const startLeft = ref(0) // 触摸起始时的left值 (px)
const startTop = ref(0) // 触摸起始时的top值 (px)
const isAnimating = ref(false) // 动画锁,防止多次触发
// 系统信息 - 直接使用px,无需rpx换算
const systemInfo = uni.getSystemInfoSync()
const screenWidthPx = systemInfo.windowWidth // 屏幕宽度(px
const screenHeightPx = systemInfo.windowHeight // 屏幕高度(px
// 容器尺寸 (px) - 直接定义像素尺寸
const CONTAINER_WIDTH = 135 // 对应原来的270rpx (750设计稿下 270/2=135px)
const CONTAINER_HEIGHT = 240 // 对应原来的480rpx (750设计稿下 480/2=240px)
console.log('系统信息:', {
screenWidthPx,
screenHeightPx,
CONTAINER_WIDTH,
CONTAINER_HEIGHT
})
// 初始化
const init = (componentProxy) => {
livePusherContext.value = uni.createLivePusherContext('livePusher', componentProxy)
}
// 切换摄像头
const switchCamera = () => {
if (!livePusherContext.value) return
livePusherContext.value.switchCamera({
success: (res) => {
console.log('livePusher.switchCamera:', JSON.stringify(res))
},
fail: (err) => {
console.error('切换摄像头失败:', err)
}
})
}
// 触摸开始事件
const handleTouchStart = (e) => {
console.log('=== 触摸开始 ===')
// 结束动画中和,不能开始
if (isAnimating.value) return
console.log('事件对象:', e)
console.log('触摸点数组:', e.touches)
try {
if (!e.touches || e.touches.length === 0) {
console.error('没有检测到触摸点')
return
}
const touch = e.touches[0]
// 安全地获取触摸坐标
const clientX = touch.screenX
const clientY = touch.screenY
startX.value = clientX
startY.value = clientY
// 记录当前容器的位置
startLeft.value = leftVal.value
startTop.value = topVal.value
console.log('记录的起始位置:', {
startX: startX.value,
startY: startY.value,
startLeft: startLeft.value,
startTop: startTop.value
})
} catch (error) {
console.error('触摸开始事件处理出错:', error)
}
}
// 触摸移动事件 - 移除rpx换算,直接使用px计算
const handleTouchMove = (e) => {
console.log('=== 触摸移动 ===')
// 防止事件穿透
if (e.stopPropagation) e.stopPropagation()
if (e.preventDefault) e.preventDefault()
// 动画中不响应触摸移动
if (isAnimating.value) return
try {
if (!e.touches || e.touches.length === 0) {
console.log('触摸移动:没有触摸点')
return
}
const touch = e.touches[0]
console.log('移动时的触摸点:', touch)
// 安全地获取当前触摸坐标
const currentX = touch.screenX
const currentY = touch.screenY
console.log('坐标比较:', {
startX: startX.value,
currentX: currentX,
startY: startY.value,
currentY: currentY
})
// 计算偏移量 (px) - 直接使用px,无需换算
const deltaX = currentX - startX.value
const deltaY = currentY - startY.value
console.log('偏移量计算:', { deltaX, deltaY })
// 检查偏移量是否有效
if (isNaN(deltaX) || isNaN(deltaY)) {
console.error('计算出 NaN 偏移量:', { deltaX, deltaY })
return
}
// 直接更新位置(px),无需rpx转换
const newLeft = startLeft.value + deltaX
const newTop = startTop.value + deltaY
// 边界检测 - 使用px单位计算边界
const maxLeft = screenWidthPx - CONTAINER_WIDTH
const maxTop = screenHeightPx - CONTAINER_HEIGHT
console.log('最终位置计算:', {
deltaX, deltaY,
newLeft, newTop,
maxLeft, maxTop,
screenWidthPx,
screenHeightPx
})
// 确保位置在有效范围内且不是 NaN
if (!isNaN(newLeft) && !isNaN(newTop)) {
leftVal.value = Math.max(0, Math.min(newLeft, maxLeft))
topVal.value = Math.max(0, Math.min(newTop, maxTop))
console.log('更新后的位置:', { left: leftVal.value, top: topVal.value })
} else {
console.error('计算出 NaN 位置:', { newLeft, newTop })
}
} catch (error) {
console.error('触摸移动事件处理出错:', error)
}
}
// 触摸结束事件 - 吸附逻辑改为px计算
const handleTouchEnd = (e) => {
console.log('=== 触摸结束 ===')
if (isAnimating.value) return
// 1. 计算核心参数(px单位)
const currentLeft = leftVal.value // 松手时小窗的左偏移
const windowCenterX = currentLeft + CONTAINER_WIDTH / 2 // 小窗中心的X坐标(px)
const screenMiddleX = screenWidthPx / 2 // 屏幕中线X坐标(px
// 2. 判断更近的边缘:中心在左半屏 → 吸附左侧;右半屏 → 吸附右侧
const targetLeft = windowCenterX < screenMiddleX ? 0 : screenWidthPx - CONTAINER_WIDTH
const targetTop = topVal.value // Y轴保持松手位置
// 3. 平滑动画过渡
const duration = 160 // 动画时长(ms
const frameCount = 60 // 动画帧数
const stepX = (targetLeft - currentLeft) / frameCount // 每帧X偏移量
const stepY = (targetTop - topVal.value) / frameCount // Y轴无偏移
let currentFrame = 0
isAnimating.value = true
const animationTimer = setInterval(() => {
currentFrame++
// 更新位置
leftVal.value += stepX
topVal.value += stepY
// 动画结束
if (currentFrame >= frameCount) {
clearInterval(animationTimer)
// 强制修正到目标位置(避免浮点误差)
leftVal.value = targetLeft
topVal.value = targetTop
isAnimating.value = false
console.log('吸附完成,最终位置:', { left: leftVal.value, top: topVal.value })
}
}, duration / frameCount)
// 仅重置触摸起始坐标
startX.value = 0
startY.value = 0
}
// 开启预览
const startPreview = () => {
console.log('摄像头开启预览')
if (!livePusherContext.value) return
livePusherContext.value.startPreview({
success: (res) => {
console.log('摄像头开启成功')
},
fail: (err) => {
console.error('开启预览失败:', err)
}
})
}
// 关闭预览
const stopPreview = () => {
console.log('关闭预览')
if (!livePusherContext.value) return
livePusherContext.value.stopPreview({
success: (res) => {
console.log('livePusher.stopPreview:', JSON.stringify(res))
},
fail: (err) => {
console.error('关闭预览失败:', err)
}
})
}
// 组件挂载后的初始化
onMounted(() => {
console.log('组件挂载完成')
// 初始化左侧位置为屏幕右侧(px单位)
leftVal.value = screenWidthPx - CONTAINER_WIDTH
console.log('初始化位置:', {
screenWidthPx,
leftVal: leftVal.value
})
})
// 暴露方法给父组件
defineExpose({
init,
switchCamera,
stopPreview,
startPreview
})
</script>
<style scoped>
.live-container {
position: absolute;
overflow: hidden;
border-radius: 10px;
width: 135px; /* 对应原来的270rpx */
height: 240px; /* 对应原来的480rpx */
background-color: red;
display: flex;
}
.livePusher{
width: 135px;
height: 240px;
border-radius: 10px;
overflow: hidden;
}
.pusher-container {
width: 135px; /* 对应原来的270rpx */
height: 240px; /* 对应原来的480rpx */
border-radius: 20px; /* 圆角大小,根据需求调整 */
overflow: hidden; /* 溢出裁剪,必须设置 */
/* 可选:添加阴影增强视觉效果 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.drag-controller {
width: 135px; /* 对应原来的270rpx */
height: 240px; /* 对应原来的480rpx */
position: absolute;
z-index: 550;
border-radius: 20px; /* 圆角大小,根据需求调整 */
overflow: hidden; /* 溢出裁剪,必须设置 */
/* 可选:添加阴影增强视觉效果 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
+225 -60
View File
@@ -1,63 +1,176 @@
<template>
<view>
<view v-if="isPreviewing" class="live-container">
<cover-view class="pusher-container">
<live-pusher
id="livePusher"
class="livePusher"
:muted="true"
:enable-camera="true"
:auto-focus="true"
:beauty="0"
:whiteness="0"
aspect="9:16"
mode="SD"
></live-pusher>
</cover-view>
</view>
<view class="live-container" :style="{ top: `${topVal}px`, left: `${leftVal}px` }">
<live-pusher
id="livePusher"
class="livePusher"
:muted="true"
:enable-camera="true"
:auto-focus="true"
:beauty="0"
:whiteness="0"
aspect="9:16"
mode="SD"
></live-pusher>
<view
class="drag-controller"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@click="handleClick"
></view>
</view>
</template>
<script setup>
import { getPhoneEnvBool } from '@/common/common';
import { onMounted, ref, reactive, nextTick } from 'vue';
import { ref, onMounted } from 'vue';
const iStatusBarHeight = uni.getSystemInfoSync().statusBarHeight;
// 响应式数据
const topVal = ref(30+iStatusBarHeight); // 初始顶部位置 (px)
const leftVal = ref(0); // 初始左侧位置 (px)
const livePusherContext = ref(null);
const isPreviewing = ref(false);
// 触摸相关数据
const startX = ref(40); // 触摸起始X坐标 (px)
const startY = ref(0); // 触摸起始Y坐标 (px)
const startLeft = ref(0); // 触摸起始时的left值 (px)
const startTop = ref(0); // 触摸起始时的top值 (px)
const isAnimating = ref(false); // 动画锁,防止多次触发
// 系统信息 - 直接使用px,无需rpx换算
const systemInfo = uni.getSystemInfoSync();
const screenWidthPx = systemInfo.windowWidth; // 屏幕宽度(px
const screenHeightPx = systemInfo.windowHeight; // 屏幕高度(px
// 容器尺寸 (px) - 直接定义像素尺寸
const CONTAINER_WIDTH = 135;
const CONTAINER_HEIGHT = 240;
// 初始化
const init = (componentProxy) => {
livePusherContext.value = uni.createLivePusherContext('livePusher', componentProxy);
livePusherContext.value.switchCamera()
};
// 切换摄像头
const switchCamera = () => {
livePusherContext.value?.switchCamera({
success: (res) => {
console.log('livePusher.switchCamera:', JSON.stringify(res));
},
fail: (err) => {
console.error('切换摄像头失败:', err);
// 触摸开始事件
const handleTouchStart = (e) => {
console.log('=== 触摸开始 ===');
// 结束动画中和,不能开始
if (isAnimating.value) return;
try {
if (!e.touches || e.touches.length === 0) {
console.error('没有检测到触摸点');
return;
}
});
const touch = e.touches[0];
// 安全地获取触摸坐标
const clientX = touch.screenX;
const clientY = touch.screenY;
startX.value = clientX;
startY.value = clientY;
// 记录当前容器的位置
startLeft.value = leftVal.value;
startTop.value = topVal.value;
} catch (error) {
console.error('触摸开始事件处理出错:', error);
}
};
// 触摸移动事件 - 移除rpx换算,直接使用px计算
const handleTouchMove = (e) => {
// 防止事件穿透
if (e.stopPropagation) e.stopPropagation();
if (e.preventDefault) e.preventDefault();
// 动画中不响应触摸移动
if (isAnimating.value) return;
try {
if (!e.touches || e.touches.length === 0) {
return;
}
const touch = e.touches[0];
// 安全地获取当前触摸坐标
const currentX = touch.screenX;
const currentY = touch.screenY;
// 计算偏移量 (px) - 直接使用px,无需换算
const deltaX = currentX - startX.value;
const deltaY = currentY - startY.value;
// 检查偏移量是否有效
if (isNaN(deltaX) || isNaN(deltaY)) {
return;
}
// 直接更新位置(px),无需rpx转换
const newLeft = startLeft.value + deltaX;
const newTop = startTop.value + deltaY;
// 边界检测 - 使用px单位计算边界
const maxLeft = screenWidthPx - CONTAINER_WIDTH;
const maxTop = screenHeightPx - CONTAINER_HEIGHT;
// 确保位置在有效范围内且不是 NaN
if (!isNaN(newLeft) && !isNaN(newTop)) {
leftVal.value = Math.max(0, Math.min(newLeft, maxLeft));
topVal.value = Math.max(0, Math.min(newTop, maxTop));
}
} catch (error) {
console.error('触摸移动事件处理出错:', error);
}
};
// 触摸结束事件 - 吸附逻辑改为px计算
const handleTouchEnd = (e) => {
console.log('=== 触摸结束 ===');
if (isAnimating.value) return;
// 1. 计算核心参数(px单位)
const currentLeft = leftVal.value; // 松手时小窗的左偏移
const windowCenterX = currentLeft + CONTAINER_WIDTH / 2; // 小窗中心的X坐标(px)
const screenMiddleX = screenWidthPx / 2; // 屏幕中线X坐标(px
// 2. 判断更近的边缘:中心在左半屏 → 吸附左侧;右半屏 → 吸附右侧
const targetLeft = windowCenterX < screenMiddleX ? 0 : screenWidthPx - CONTAINER_WIDTH;
const targetTop = topVal.value; // Y轴保持松手位置
// 3. 平滑动画过渡
const duration = 160; // 动画时长(ms
const frameCount = 60; // 动画帧数
const stepX = (targetLeft - currentLeft) / frameCount; // 每帧X偏移量
const stepY = (targetTop - topVal.value) / frameCount; // Y轴无偏移
let currentFrame = 0;
isAnimating.value = true;
const animationTimer = setInterval(() => {
currentFrame++;
// 更新位置
leftVal.value += stepX;
topVal.value += stepY;
// 动画结束
if (currentFrame >= frameCount) {
clearInterval(animationTimer);
// 强制修正到目标位置(避免浮点误差)
leftVal.value = targetLeft;
topVal.value = targetTop;
isAnimating.value = false;
console.log('吸附完成,最终位置:', { left: leftVal.value, top: topVal.value });
}
}, duration / frameCount);
// 仅重置触摸起始坐标
startX.value = 0;
startY.value = 0;
};
// 开启预览
const startPreview = () => {
console.log('摄像头开启预览');
isPreviewing.value = true;
nextTick(() => {
livePusherContext.value?.startPreview({
success: (res) => {
console.log('摄像头开启成功');
},
fail: (err) => {
console.error('开启预览失败:', err);
},
complete: (err) => {
console.error('complete预览:', err);
}
});
if (getPhoneEnvBool('AND')) {
console.log('切换前置');
if (!livePusherContext.value) return;
livePusherContext.value.startPreview({
success: (res) => {
console.log('摄像头开启成功');
},
fail: (err) => {
console.error('开启预览失败:', err);
}
});
};
@@ -65,30 +178,82 @@
// 关闭预览
const stopPreview = () => {
console.log('关闭预览');
livePusherContext.value?.stopPreview({
if (!livePusherContext.value) return;
livePusherContext.value.stopPreview({
success: (res) => {
console.log('livePusher.stopPreview:', JSON.stringify(res));
},
fail: (err) => {
console.error('关闭预览失败:', err);
},
complete: (err) => {
console.error('complete预览:', err);
console.log('关闭成功');
}
});
isPreviewing.value = false;
};
defineExpose({ init, switchCamera, stopPreview, startPreview });
const lastClickTime = ref(0);
let clickTimer = null;
const handleClick = () => {
// 2. 双击判断逻辑
const currentTime = new Date().getTime(); // 当前点击时间戳
const timeDiff = currentTime - lastClickTime.value; // 两次点击的时间差
if (timeDiff > 0 && timeDiff <= 300) {
switchCamera();
// 清空上次点击时间,避免连续点击重复触发
lastClickTime.value = 0;
// 清除定时器(如果有)
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
}
} else {
// 2.2 第一次点击 → 记录时间,设置定时器(超时则清空)
lastClickTime.value = currentTime;
if (clickTimer) {
clearTimeout(clickTimer);
}
clickTimer = setTimeout(() => {
lastClickTime.value = 0; // 超时未触发双击,重置
}, 300);
}
};
// 切换摄像头
const switchCamera = () => {
if (!livePusherContext.value) return;
livePusherContext.value.switchCamera({
success: (res) => {},
});
};
// 组件挂载后的初始化
onMounted(() => {
leftVal.value = screenWidthPx - CONTAINER_WIDTH;
});
// 暴露方法给父组件
defineExpose({
init,
switchCamera,
stopPreview,
startPreview
});
</script>
<style scoped>
.live-container {
position: absolute;
right: 0;
top: 150rpx;
overflow: hidden;
border-radius: 20rpx;
width: 270rpx;
height: 480rpx;
width: 135px;
height: 240px;
flex: 1;
display: flex;
}
.livePusher {
width: 135px;
height: 240px;
border-radius: 10px;
overflow: hidden;
}
.drag-controller {
width: 135px;
height: 240px;
position: absolute;
z-index: 550;
overflow: hidden;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
export default function useCamera(aaas = null, options = {}) {
return {
ws,
close,
initConnection,
send,
isRecording
};
}
+7 -2
View File
@@ -72,7 +72,6 @@ export default function useWebSocket(aaas = null, options = {}) {
body
} = ProtocolCodec.unpack(res.data);
switch (msgType) {
case MessageType.IDENTITY:
console.log('身份校验', body);
onStartRecord(body)
@@ -89,16 +88,22 @@ export default function useWebSocket(aaas = null, options = {}) {
case 1:
// ASR或TTS初始化出错
console.log('后端返回的错误', body.errMsg);
case 4:
console.log('通话接通失败', body.errMsg);
// 通话接通失败,请稍后再试!
ws.value.close({
code: 4001,
reason: '当前服务器繁忙,请稍后在试!'
})
break;
case 5: // 使用人数过多等其他错误
ws.value.close({
code: 4001,
reason: body.errMsg
})
break;
default:
console.log('其他错误码的默认处理', body);
// 其他错误码的默认处理(如果需要)
// ws.value.close({
// code: 1001,
+12 -11
View File
@@ -4,8 +4,6 @@
<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>
@@ -47,9 +45,7 @@
} from '@dcloudio/uni-app';
import {
ProtocolCodec,
MessageType,
CompressionType,
ControlCommand
MessageType
} from './js/ProtocolCodec';
import common from '@/common/common';
import talkMicrophone from './components/talk-microphone.vue';
@@ -130,10 +126,10 @@
}
};
// 未接通时候报错处理,仅返回和少量销毁
const noConnectDestroy = () => {
const noConnectDestroy = (msg) => {
console.log('未接通');
setTimeout(() => {
common.show('', '当前服务器繁忙,请稍后在试!', false).then((res) => {
common.show('', msg, false).then((res) => {
if (res) common.navigateBack();
});
}, 500);
@@ -220,7 +216,7 @@
destroy();
} else {
// 否则是未接通
noConnectDestroy();
noConnectDestroy(error?.reason);
}
} else if (error?.code === 1001) {
// 正常运行中后端掉线了
@@ -234,7 +230,7 @@
// 用户主动挂断
} else if (error?.code === 4001) {
// 自定义异常挂断,参数错误
noConnectDestroy();
noConnectDestroy(error?.reason);
}
};
onLoad((e) => {
@@ -282,7 +278,12 @@
console.log('组件卸载');
if (timer) clearInterval(timer);
});
onBackPress((res) => res.from === 'backbutton');
onBackPress((res) => {
if (res.from === 'backbutton') {
closeTalking();
return true;
}
});
</script>
<style scoped lang="scss">
@@ -470,4 +471,4 @@
margin-bottom: 112rpx;
flex-shrink: 0;
}
</style>
</style>
+325
View File
@@ -0,0 +1,325 @@
<template>
<view class="max-div">
<cover-view class="pusher-container">
<view class="show-box" @click.stop>
<image :src="dataInfo.ossAddr" class="show-box-avatar"></image>
<view :style="{ height: iStatusBarHeight + 'px' }"></view>
<view class="switch-div">
<text class="title-time" v-if="isRecording">{{ formattedTime }}</text>
<text class="title-tip" v-if="!isRecording">正在接通中</text>
</view>
<view class="fl1"></view>
<view class="close-btn" @click="closeTalking">
<image src="/src/static/images/sparring/close-talk.png" mode="" class="close-btn-img"></image>
</view>
<talk-camera
ref="talkCameraRef"
></talk-camera>
</view>
</cover-view>
<talk-microphone ref="talkMicrophoneRef"></talk-microphone>
<talk-speaker ref="talkSpeakerRef" @playOver="playOver"></talk-speaker>
</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 imagePreviewNvue from '@/components/image-preview/image-preview-nvue.vue';
import { get_base_url } from '@/api/request.js';
import { getToken } from '@/common/common.js';
import { ProtocolCodec, MessageType } 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 instance = getCurrentInstance();
const componentProxy = instance.ctx;
const talkMicrophoneRef = ref(null);
const talkSpeakerRef = ref(null);
const talkCameraRef = ref(null);
const { initConnection, close, send, isRecording } = useWebSocket();
const iStatusBarHeight = ref(20);
const isVideo = ref(false);
const livePusherContext = ref(null);
const dataInfo = reactive({
traId: '',
chaId: '',
traType: '',
traInterTyp: '',
execId: '',
sceneDesc: '',
preview: false, // 是否是预览
traInterTypDesc: '', // 类型的文字提示,例如 语音通话
ossAddr: '', //用户头像
chaName: '' //用户名称
});
// 关闭通话
const closeTalking = async () => {
// todo 防抖
common.show('', '挂断视频将结束训练,是否确认?').then((res) => {
if (res) {
destroy(); // 走关闭销毁流程
}
});
};
const handleTouchStart = () => {
isVideo.value && talkCameraRef.value.switchCamera();
};
// 关闭销毁流程,逐层清理
const destroy = (type = 0) => {
if (type <= 0) {
isVideo.value && talkCameraRef.value.stopPreview();
talkMicrophoneRef.value && talkMicrophoneRef.value.stop();
talkSpeakerRef.value && 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 &&
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';
iStatusBarHeight.value = uni.getSystemInfoSync().statusBarHeight;
//#endif
dataInfo.preview = e.isPreview === '1'; // 是否是预览
const pageData = common.getPageCache('tipMsgsceneDesc');
// const pageData = {
// ossAddr: '/traoss/tras3/show/S3F0250181220722025120514463400000100717',
// chaName: '陈先生',
// traInterTyp: '03',
// traInterTypDesc: '视频通话'
// };
const base_url = get_base_url();
const token = getToken();
dataInfo.ossAddr = base_url + pageData.ossAddr + '?tk=' + token;
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) {
talkCameraRef.value.init(componentProxy);
}
});
onUnmounted(() => {
console.log('组件卸载');
if (timer) clearInterval(timer);
});
onBackPress((res) => {
if (res.from === 'backbutton') {
closeTalking();
return true;
}
});
</script>
<style scoped lang="scss">
.fl1 {
flex: 1;
}
.max-div {
width: 750rpx;
flex: 1;
position: relative;
display: flex;
}
.show-box-avatar {
z-index: 400;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex: 1;
display: flex;
}
.show-box {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
background-color: rgba(0, 0, 0, 0.2);
flex: 1;
}
.title-time,
.title-tip {
color: #fff;
font-size: 38rpx;
}
.close-btn-img {
width: 118rpx;
height: 118rpx;
margin-top: 46px;
margin-bottom: 112px;
}
.pusher-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 200;
flex: 1;
display: flex;
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<template>
<view>
<image-preview :src="dataInfo.ossAddr" class="show-box-avatar"></image-preview>
<view class="show-box" >
<nav-bar :is_seat="true"></nav-bar>
<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>
</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 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('', '挂断电话将结束训练,是否确认?').then((res) => {
if (res) {
destroy(); // 走关闭销毁流程
}
});
};
// 关闭销毁流程,逐层清理
const destroy = (type = 0) => {
if (type <= 0) {
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;
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 = '02'; // 陪练交互类型 01 对话 02 语音 03 视频
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
});
});
onUnmounted(() => {
console.log('组件卸载');
if (timer) clearInterval(timer);
});
onBackPress((res) => {
if (res.from === 'backbutton') {
closeTalking();
return true;
}
});
</script>
<style scoped lang="scss">
.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>
+23 -14
View File
@@ -69,7 +69,9 @@
<!-- sceneDesc -->
{{ detailInfo.sceneDesc }}
</view>
<view class="talking-desc">陪练角色不具备主动终止场景陪练的功能当用户完成预设陪练目标时需由用户手动发起结束陪练操作</view>
<view class="talking-desc">
陪练角色不具备主动终止场景陪练的功能当用户完成预设陪练目标时需由用户手动发起结束陪练操作
</view>
<view class="talking-box" v-if="detailInfo.traInterTyp !== '01'">
<view class="tip-text">-请点击下方按钮开始{{ detailInfo.traInterTypDesc }}</view>
</view>
@@ -141,7 +143,7 @@
import points from '@/components/points-and-badge/points';
import usePointsAndBadge from '@/components/points-and-badge/usePointsAndBadge';
import { initRecord } from '@/composables/useExamSubject.js';
import permissionApi from '@/common/permission'
import permissionApi from '@/common/permission';
const { pointAndBadgesRun, badgeRef, pointsRef } = usePointsAndBadge();
const socketStore = useSocketStore();
@@ -247,26 +249,33 @@
const toTalking = async () => {
try {
//#ifdef APP-PLUS
console.log('申请权限');
try {
// await initRecord();
await permissionApi.judgeIosPermission('record')
await permissionApi.judgeIosPermission('camera')
} catch(e) {
console.error('摄像头或者麦克风权限申请失败:', e);
return;
console.log('申请权限');
try {
await permissionApi.judgeIosPermission('record');
if (detailInfo.value.traInterTyp === '03') {
await permissionApi.judgeIosPermission('camera');
}
} catch (e) {
console.error('摄像头或者麦克风权限申请失败:', e);
return;
}
//#endif
common.setPageCache('tipMsgsceneDesc', {
ossAddr: detailInfo.value.ossAddr,
chaName: detailInfo.value.chaName,
traInterTyp: detailInfo.value.traInterTyp,
traInterTypDesc: detailInfo.value.traInterTypDesc,
traInterTypDesc: detailInfo.value.traInterTypDesc
});
let _gotUrl;
if (detailInfo.value.traInterTyp === '02') {
_gotUrl = '/pages/sparring/talk';
} else if (detailInfo.value.traInterTyp === '03') {
_gotUrl = '/pages/sparring/talk'; // 没改好,暂时跳到一个页
}
common.redirectTo(
'/pages/sparring/talk?traId=' +
_gotUrl +
'?traId=' +
traId.value +
'&chaId=' +
chaId.value +
@@ -899,7 +908,7 @@
text-align: center;
}
}
.talking-desc{
.talking-desc {
// padding-top: 24rpx;
padding: 24rpx 20rpx 0;
font-size: 24rpx;
+1 -1
View File
@@ -36,7 +36,7 @@
}
const aaaa = async() => {
common.navigateTo('/pages/test/testChat')
}
</script>
+101
View File
@@ -0,0 +1,101 @@
<template>
<view>
<live-pusher id='livePusher' ref="livePusher" class="livePusher" url=""
mode="SD" :muted="true" :enable-camera="true" :auto-focus="true" :beauty="1" whiteness="2"
aspect="9:16" @statechange="statechange" @netstatus="netstatus" @error = "error"
></live-pusher>
<button class="btn" @click="start">开始推流</button>
<button class="btn" @click="pause">暂停推流</button>
<button class="btn" @click="resume">resume</button>
<button class="btn" @click="stop">停止推流</button>
<button class="btn" @click="snapshot">快照</button>
<button class="btn" @click="startPreview">开启摄像头预览</button>
<button class="btn" @click="stopPreview">关闭摄像头预览</button>
<button class="btn" @click="switchCamera">切换摄像头</button>
</view>
</template>
<script>
export default {
data() {
return {}
},
onReady() {
// 注意:需要在onReady中 或 onLoad 延时
this.context = uni.createLivePusherContext("livePusher", this);
},
methods: {
statechange(e) {
console.log("statechange:" + JSON.stringify(e));
},
netstatus(e) {
console.log("netstatus:" + JSON.stringify(e));
},
error(e) {
console.log("error:" + JSON.stringify(e));
},
start: function() {
this.context.start({
success: (a) => {
console.log("livePusher.start:" + JSON.stringify(a));
}
});
},
close: function() {
this.context.close({
success: (a) => {
console.log("livePusher.close:" + JSON.stringify(a));
}
});
},
snapshot: function() {
this.context.snapshot({
success: (e) => {
console.log(JSON.stringify(e));
}
});
},
resume: function() {
this.context.resume({
success: (a) => {
console.log("livePusher.resume:" + JSON.stringify(a));
}
});
},
pause: function() {
this.context.pause({
success: (a) => {
console.log("livePusher.pause:" + JSON.stringify(a));
}
});
},
stop: function() {
this.context.stop({
success: (a) => {
console.log(JSON.stringify(a));
}
});
},
switchCamera: function() {
this.context.switchCamera({
success: (a) => {
console.log("livePusher.switchCamera:" + JSON.stringify(a));
}
});
},
startPreview: function() {
this.context.startPreview({
success: (a) => {
console.log("livePusher.startPreview:" + JSON.stringify(a));
}
});
},
stopPreview: function() {
this.context.stopPreview({
success: (a) => {
console.log("livePusher.stopPreview:" + JSON.stringify(a));
}
});
}
}
}
</script>
-38
View File
@@ -1,38 +0,0 @@
<template>
<view class="">
<view class="cssss">
<CameraPreview ref="CameraPreviewRef"></CameraPreview>
</view>
<view style="margin-top: 400rpx">
<button class="btn" @click="startPreview">开启摄像头预览</button>
<button class="btn" @click="stopPreview">关闭摄像头预览</button>
<button class="btn" @click="switchCamera">切换摄像头</button>
</view>
</view>
</template>
<script setup>
import CameraPreview from './CameraPreview';
import { onReady } from '@dcloudio/uni-app';
import { ref, getCurrentInstance } from 'vue';
const CameraPreviewRef = ref(null);
const instance = getCurrentInstance();
const componentProxy = instance.ctx;
const startPreview = () => {
CameraPreviewRef.value.startPreview();
};
const stopPreview = () => {
CameraPreviewRef.value.stopPreview();
};
const switchCamera = () => {
CameraPreviewRef.value.switchCamera();
};
onReady(() => {
CameraPreviewRef.value.init(componentProxy);
});
</script>
<style>
</style>
+467 -14
View File
@@ -1,20 +1,473 @@
<template>
<view class="">
<CameraPreview ref="CameraPreviewRef"></CameraPreview>
<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 CameraPreview from './CameraPreview'
import { onReady } from '@dcloudio/uni-app';
import { ref , getCurrentInstance} from 'vue';
const CameraPreviewRef = ref(null);
const instance = getCurrentInstance();
const componentProxy = instance.ctx
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(() => {
CameraPreviewRef.value.init(componentProxy)
})
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>
+3 -2
View File
@@ -8,7 +8,7 @@
<view>考试详情</view>
</view>
</template>
<view class="main_div max_page">
<view class="main_div">
<view class="detail_div" :class="detailData.examStatText == 'Y' ? 'completed' : 'incomplete'">
<view class="subscript" v-if="detailData.examStatText === 'N'">未完成</view>
<view class="subscript" v-if="detailData.examStatText === 'Y'">已完成</view>
@@ -297,7 +297,8 @@
width: 100%;
min-height: 100%;
flex: 1;
z-index: 2;
background-color: #ffffff;
.paper {
height: 100%;
margin: 24rpx 30rpx 0 30rpx;
File diff suppressed because it is too large Load Diff
+10
View File
@@ -101,6 +101,16 @@
}
}
},
{
"path": "pages/sparring/talkVideo",
"style": {
"navigationBarTitleText": "AI陪练视频通话",
"disableSwipeBack": true,
"app-plus": {
"titleNView": false
}
}
},
{
"path": "pages/sparring/result",
"style": {
+4 -1
View File
@@ -1,3 +1,6 @@
// #ifndef APP-PLUS-NVUE
import { createPinia } from 'pinia';
const store = createPinia();
export default store;
export default store;
// #endif
+1
View File
@@ -4,6 +4,7 @@ import { getToken } from '@/common/common.js'
import { get_base_url } from '@/api/request.js'
import SocketClass from '@/api/socket.js'
export const useSocketStore = defineStore('socketStore',{
state:()=>({
SocketObj: null
+1
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useStorageStore = defineStore('storageStore',{
state:()=>({
imgObjStr: '{}',