From 97ca74a2251c3df18b2be812a6e99acaa689f30f Mon Sep 17 00:00:00 2001 From: zhanghao <774378400@qq.com> Date: Tue, 14 Jul 2026 16:35:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/IPlayer/config.ts | 2 +- src/components/IPlayer/index.vue | 13 +- .../ManualTakeover/VoiceConversation.vue | 207 +++++++++++------- .../components/ManualTakeover/index.vue | 7 +- .../inspection/cockpit/components/Monitor.vue | 46 +++- 5 files changed, 183 insertions(+), 92 deletions(-) diff --git a/src/components/IPlayer/config.ts b/src/components/IPlayer/config.ts index 3048428..116e133 100644 --- a/src/components/IPlayer/config.ts +++ b/src/components/IPlayer/config.ts @@ -12,7 +12,7 @@ export const conf = { startTime: 0, // 点播模式下,初始起播时间 videoAttributes: {}, // video扩展属性,暂且不配置 lang: 'zh-cn', // 播放器初始显示语言,设置为中文 - fluid: true, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果 + fluid: false, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果 fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来 videoFillMode: 'auto', // 宽高不够自动底色填充(fill拉伸填充等...) seekedStatus: 'play', // 跳转后继续播放 diff --git a/src/components/IPlayer/index.vue b/src/components/IPlayer/index.vue index 55be899..1f6d5a2 100644 --- a/src/components/IPlayer/index.vue +++ b/src/components/IPlayer/index.vue @@ -9,10 +9,14 @@ import { ref, onMounted } from 'vue'; import Player, { Events } from 'xgplayer'; // 引入西瓜视频模块 import 'xgplayer/dist/index.min.css'; // 引入西瓜视频样式 -const { videoUrl } = defineProps({ +const { videoUrl, isLive } = defineProps({ videoUrl: { type: String, default: '' + }, + isLive: { + type: Boolean, + default: false } }) @@ -29,7 +33,7 @@ const init = () => { el: xgPlayerRef.value, // width: 600, // height: 400, // 视频宽高尺寸 - isLive: false, + isLive: isLive, url: videoUrl, // 视频源 // poster: "http://ashuai.work/static/img/avantar.png", // 视频封面 ...conf, @@ -53,8 +57,11 @@ const init = () => { .player_container { width: 100%; height: 100%; + min-height: 0; + overflow: hidden; - .xgplayer { + :deep(.xgplayer) { + width: 100% !important; height: 100% !important; } } diff --git a/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue b/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue index b597862..5299251 100644 --- a/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue +++ b/src/views/inspection/cockpit/components/ManualTakeover/VoiceConversation.vue @@ -57,17 +57,7 @@

当前状态:{{ statusMeta.label }}

{{ statusMeta.description }} - - - +
高清语音 低延迟 @@ -87,8 +77,11 @@ import { computed, onBeforeUnmount, ref } from 'vue' import { ElMessage, ElNotification } from 'element-plus' // 请替换为实际部署的信令服务器地址。 -const SIGNALING_SERVER_URL = 'wss://your-signaling-server.com' -const ROOM_NAME = 'voice-room' +const SIGNALING_SERVER_URL = 'ws://192.168.1.222:13080/ws/signaling' +// 后端创建语音会话所需的固定终端、音频设备和操作员标识。 +const TERMINAL_ID = 'c2f8a06826baf03843925c1a2a13bcfd' +const DEVICE_ID = '1234' +const OPERATOR_ID = 'user001' const RTC_CONFIGURATION = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] } @@ -107,12 +100,13 @@ const isStarting = ref(false) const isConnected = ref(false) const isMuted = ref(false) const connectionStatus = ref('idle') -const errorMessage = ref('') // 音量分析和异步通话过程使用的非响应式资源。 let audioContext = null let analyser = null let volumeAnimationFrame = null +// 后端返回 joined 后生成的业务会话 ID,后续 Offer、Candidate、心跳和停止消息都必须携带。 +let signalingSessionId = '' // 远端 SDP 设置完成前收到的 ICE 候选会暂存在这里,避免 addIceCandidate 调用失败。 let pendingIceCandidates = [] // 每次开始或清理通话都会更新会话编号,用于让旧会话的异步回调自动失效。 @@ -129,18 +123,6 @@ const statusMeta = computed(() => { return statusMap[connectionStatus.value] || statusMap.idle }) -/** - * 生成当前房间的 WebSocket 信令地址。 - * 将固定房间名放入 URL 查询参数,使信令服务器可以把消息转发给同房间的另一端。 - * @returns {string} 带 room 参数的完整 WebSocket 地址 - */ -function getSignalingUrl() { - // 如果原地址已经带有查询参数,则使用 & 继续拼接,否则使用 ? 开始查询参数。 - const separator = SIGNALING_SERVER_URL.includes('?') ? '&' : '?' - // 对房间名进行 URL 编码,避免特殊字符破坏 WebSocket 地址。 - return `${SIGNALING_SERVER_URL}${separator}room=${encodeURIComponent(ROOM_NAME)}` -} - /** * 统一处理通话过程中的异常。 * 该方法会记录错误、更新页面状态,并使用 Element Plus 通知用户。 @@ -150,8 +132,6 @@ function getSignalingUrl() { function showError(message, error) { // 将完整错误输出到控制台,方便开发环境定位具体异常。 console.error(message, error || '') - // 保存用户可读的错误文案,以便状态卡显示错误提示。 - errorMessage.value = message // 将内部状态切换为错误状态,驱动状态灯和说明文字更新。 connectionStatus.value = 'error' // 明确标记当前已经不处于 WebRTC 已连接状态。 @@ -180,7 +160,7 @@ function sendSignalingMessage(message) { /** * 创建并初始化 RTCPeerConnection。 - * 作用包括:加入本地麦克风轨道、接收并播放远端音频、发送 ICE 候选, + * 作用包括:接收并播放远端音频、发送 ICE 候选, * 以及监听连接状态来同步页面上的“连接中/通话中/未连接”状态。 * @param {number} sessionId 本次通话的会话编号,用于忽略过期回调 * @returns {RTCPeerConnection} 初始化完成的 WebRTC 连接实例 @@ -196,12 +176,6 @@ function setupPeerConnection(sessionId) { // 将远端媒体流绑定到远端 audio 元素,轨道到达后即可播放。 if (remoteAudio.value) remoteAudio.value.srcObject = remoteStream.value - // 遍历本地麦克风流中的所有轨道,并将其发布到 WebRTC 连接中。 - localStream.value.getTracks().forEach((track) => { - // addTrack 会触发 SDP 中相应媒体能力的协商。 - pc.addTrack(track, localStream.value) - }) - // 对端音频轨道到达后,将轨道加入远端媒体流并交给 audio 元素播放。 pc.ontrack = (event) => { // 如果回调属于已经结束的旧会话,则直接忽略,避免污染新通话。 @@ -227,10 +201,13 @@ function setupPeerConnection(sessionId) { try { // 按约定协议发送候选地址、媒体行索引和媒体标识。 sendSignalingMessage({ - type: 'ice-candidate', - candidate: event.candidate.candidate, - sdpMLineIndex: event.candidate.sdpMLineIndex, - sdpMid: event.candidate.sdpMid + type: 'candidate', + sessionId: signalingSessionId, + candidate: { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + } }) } catch (error) { // 信令连接异常时进入统一错误处理流程。 @@ -248,8 +225,6 @@ function setupPeerConnection(sessionId) { isConnected.value = true // 切换为通话中状态,启用页面声波和光晕动画。 connectionStatus.value = 'connected' - // 成功连接后清除之前可能存在的错误信息。 - errorMessage.value = '' // 向用户提示语音通道已经成功建立。 ElMessage.success('实时语音连接已建立') // failed 或 disconnected 表示媒体通道已经不可用。 @@ -281,9 +256,8 @@ async function flushPendingIceCandidates(pc) { /** * 处理信令服务器发来的消息。 - * - offer:设置远端描述,创建并返回 answer; * - answer:完成发起端的远端描述设置; - * - ice-candidate:立即添加候选,或等待远端 SDP 就绪后再添加。 + * - candidate:立即添加候选,或等待远端 SDP 就绪后再添加。 * @param {MessageEvent} event WebSocket 消息事件 * @param {number} sessionId 本次通话的会话编号 * @returns {Promise} @@ -304,42 +278,40 @@ async function handleSignalingMessage(event, sessionId) { return } + // 后端消息携带其他会话 ID 时直接忽略,防止串话或旧会话消息污染当前连接。 + if (message.sessionId && signalingSessionId && message.sessionId !== signalingSessionId) return + // 获取当前 WebRTC 实例,后续 SDP 和 ICE 都需要写入该实例。 const pc = peerConnection.value // 通话可能已经被清理,没有连接实例时直接忽略消息。 if (!pc) return try { - // 收到 Offer 表示对端希望发起或重新进行媒体能力协商。 - if (message.type === 'offer') { - // 将对端 Offer 设置为远端描述,使浏览器了解对端媒体能力。 - await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: message.sdp })) - // 远端描述就绪后,补充此前因时序原因暂存的 ICE 候选。 - await flushPendingIceCandidates(pc) - // 根据远端 Offer 和本地能力生成应答 SDP。 - const answer = await pc.createAnswer() - // 保存本地 Answer,使 WebRTC 连接进入正确的信令状态。 - await pc.setLocalDescription(answer) - // 将 Answer 通过信令服务器返回给发起方。 - sendSignalingMessage({ type: 'answer', sdp: answer.sdp }) - // 收到 Answer 表示对端已经响应了本地发送的 Offer。 - } else if (message.type === 'answer') { + // 当前协议固定由浏览器发送 Offer,后端只需返回对应的 Answer。 + if (message.type === 'answer') { // 设置远端 Answer,完成 SDP Offer/Answer 协商闭环。 await pc.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: message.sdp })) // SDP 就绪后添加在 Answer 到达前收到的 ICE 候选。 await flushPendingIceCandidates(pc) // ICE 候选用于告知本端对端可能可用的网络地址和传输路径。 - } else if (message.type === 'ice-candidate') { + } else if (message.type === 'candidate') { // 使用协议字段还原浏览器可识别的 RTCIceCandidate 对象。 const candidate = new RTCIceCandidate({ - candidate: message.candidate, - sdpMLineIndex: message.sdpMLineIndex, - sdpMid: message.sdpMid + candidate: message.candidate?.candidate, + sdpMLineIndex: message.candidate?.sdpMLineIndex, + sdpMid: message.candidate?.sdpMid }) // 已有远端描述时可以立即把候选加入 WebRTC 连接。 if (pc.remoteDescription) await pc.addIceCandidate(candidate) // 远端描述尚未到达时先缓存候选,等待 SDP 设置完成后处理。 else pendingIceCandidates.push(candidate) + // 后端主动报告会话错误时停止继续协商并显示错误原因。 + } else if (message.type === 'error') { + showError(message.message || '语音会话发生异常') + // 后端确认会话停止后同步恢复页面状态。 + } else if (message.type === 'stopped' || message.type === 'stop') { + cleanupResources(false) + connectionStatus.value = 'idle' } } catch (error) { // 捕获 SDP 设置、Answer 创建或候选添加过程中的所有异常。 @@ -354,24 +326,75 @@ async function handleSignalingMessage(event, sessionId) { * @returns {Promise} 连接成功后返回 WebSocket 实例 */ function connectSignalingServer(sessionId) { - // 返回 Promise,让 startCall 可以等待 WebSocket 真正连接成功后再发送 Offer。 + // Promise 只有在后端返回 joined 后才完成,确保 Offer 一定携带有效 sessionId。 return new Promise((resolve, reject) => { - // 使用带房间参数的地址创建信令 WebSocket。 - const socket = new WebSocket(getSignalingUrl()) - // 保存实例,供发送信令及结束通话时关闭连接。 + // 直接连接后端提供的语音信令地址。 + const socket = new WebSocket(SIGNALING_SERVER_URL) + // 保存实例,供发送信令以及通话结束时关闭连接。 webSocket.value = socket + // 标记 joined 是否已经返回,用于区分建连失败和通话中断线。 + let hasJoined = false - // 连接成功时兑现 Promise,允许后续 SDP 协商继续执行。 - socket.onopen = () => resolve(socket) - // 每条服务端消息都交由统一的信令处理方法按 type 分发。 - socket.onmessage = (event) => handleSignalingMessage(event, sessionId) - // 建连阶段发生错误时拒绝 Promise,由 startCall 捕获并清理资源。 - socket.onerror = () => reject(new Error('WebSocket 连接失败')) - // 监听连接关闭,识别通话过程中意外断开信令服务器的情况。 + // WebSocket 建立后必须先发送 join,由后端创建会话并打开机器人 gRPC 音频流。 + socket.onopen = () => { + sendSignalingMessage({ + type: 'join', + terminalId: TERMINAL_ID, + deviceId: DEVICE_ID, + operatorId: OPERATOR_ID + }) + } + + // joined 消息在此处完成启动流程,其他消息继续交给 WebRTC 信令处理方法。 + socket.onmessage = (event) => { + let message + try { + // 先解析一次消息,以识别建立后端会话所需的 joined 响应。 + message = JSON.parse(event.data) + } catch (error) { + // 非法消息交给统一处理方法展示协议错误。 + handleSignalingMessage(event, sessionId) + return + } + + // 后端成功创建会话后保存业务 sessionId,并开始前端心跳。 + if (message.type === 'joined') { + // 缺少 sessionId 时无法发送后续信令,因此直接判定加入失败。 + if (!message.sessionId) { + reject(new Error('后端 joined 响应缺少 sessionId')) + return + } + // 忽略旧通话延迟到达的 joined 响应。 + if (sessionId !== callSessionId) return + // 保存后端会话 ID,供本次通话的全部后续消息使用。 + signalingSessionId = message.sessionId + // 标记后端会话已经创建成功。 + hasJoined = true + // 将 joined 数据返回 startCall,允许继续创建并发送 Offer。 + resolve(message) + return + } + + // join 阶段收到后端错误时立即终止等待,让 startCall 进入异常清理流程。 + if (message.type === 'error' && !hasJoined) { + reject(new Error(message.message || '后端创建语音会话失败')) + return + } + + // joined 之外的 Answer、Candidate、错误等消息按 WebRTC 协议处理。 + handleSignalingMessage(event, sessionId) + } + + // WebSocket 底层异常时让尚未完成的启动流程失败。 + socket.onerror = () => { + if (!hasJoined) reject(new Error('WebSocket 连接失败')) + } + + // 监听信令连接关闭,识别加入过程中失败或通话期间意外断线。 socket.onclose = () => { - // 仅当前会话仍处于通话状态时才提示异常,主动挂断不应报错。 - if (sessionId === callSessionId && isCalling.value) { - // 信令连接意外关闭时更新错误状态并提醒用户。 + if (!hasJoined) { + reject(new Error('信令服务器在会话建立前已断开')) + } else if (sessionId === callSessionId && isCalling.value) { showError('信令服务器连接已断开') } } @@ -429,10 +452,13 @@ async function startCall() { isStarting.value = true // 立即将 UI 更新为连接中状态。 connectionStatus.value = 'connecting' - // 新一轮连接开始前清空上一次的错误提示。 - errorMessage.value = '' try { + // 第一步连接信令服务器、发送 join,并等待后端返回 joined 和业务 sessionId。 + await connectSignalingServer(sessionId) + // joined 成功后创建 WebRTC 连接,用于承载后续双向音频轨道。 + const pc = setupPeerConnection(sessionId) + // 请求用户授权并采集仅包含音频的本地媒体流。 const stream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -460,17 +486,17 @@ async function startCall() { if (localAudio.value) localAudio.value.srcObject = stream // 启动麦克风音量分析,为声波可视化提供数据基础。 startVolumeMeter(stream) - // 创建 WebRTC 实例并把本地音频轨道加入连接。 - const pc = setupPeerConnection(sessionId) - // 等待信令服务器连接成功,确保随后的 Offer 不会发送失败。 - await connectSignalingServer(sessionId) + // 将麦克风音轨加入 WebRTC;连接建立后浏览器会自动通过 WebRTC 发送音频。 + stream.getAudioTracks().forEach((track) => { + pc.addTrack(track, stream) + }) // 根据本地音频能力生成发起协商所需的 SDP Offer。 - const offer = await pc.createOffer() + const offer = await pc.createOffer({ offerToReceiveAudio: true }) // 设置本地描述,正式启动 ICE 候选收集过程。 await pc.setLocalDescription(offer) // 按约定协议将 Offer 发送给同一房间的远端用户。 - sendSignalingMessage({ type: 'offer', sdp: offer.sdp }) + sendSignalingMessage({ type: 'offer', sessionId: signalingSessionId, sdp: offer.sdp }) // 标记通话已启动;最终媒体连通状态仍由 connectionstatechange 更新。 isCalling.value = true } catch (error) { @@ -517,7 +543,17 @@ function toggleMute() { * 包括停止音量分析、关闭 AudioContext/WebRTC/WebSocket、停止本地和远端轨道, * 清空 audio 元素并重置通话相关状态。该方法可重复调用,供异常、挂断和卸载共同使用。 */ -function cleanupResources() { +function cleanupResources(notifyBackend = true) { + // 主动挂断或启动失败时,先通知后端停止 gRPC 音频流并释放机器人会话。 + if (notifyBackend && signalingSessionId && webSocket.value?.readyState === WebSocket.OPEN) { + try { + // 按后端协议发送停止消息;即使发送失败也必须继续释放本地资源。 + sendSignalingMessage({ type: 'stop', sessionId: signalingSessionId }) + } catch (error) { + // 仅记录停止消息的发送异常,避免它中断后续清理流程。 + console.warn('停止会话消息发送失败', error) + } + } // 更新会话编号,使当前会话尚未完成的所有异步回调立即失效。 callSessionId += 1 // 清空未处理 ICE 候选,防止带入下一次通话。 @@ -587,6 +623,8 @@ function cleanupResources() { isConnected.value = false // 下次通话默认从非静音状态开始。 isMuted.value = false + // 清空后端业务会话 ID,避免下一次通话误用旧 sessionId。 + signalingSessionId = '' } /** @@ -599,7 +637,6 @@ function endCall() { // 将状态恢复为未连接,更新状态标签和说明文案。 connectionStatus.value = 'idle' // 主动结束后清除错误信息,避免旧错误继续显示。 - errorMessage.value = '' // 向用户确认通话已经结束。 ElMessage.success('通话已结束') } diff --git a/src/views/inspection/cockpit/components/ManualTakeover/index.vue b/src/views/inspection/cockpit/components/ManualTakeover/index.vue index 777983e..e5df2b6 100644 --- a/src/views/inspection/cockpit/components/ManualTakeover/index.vue +++ b/src/views/inspection/cockpit/components/ManualTakeover/index.vue @@ -99,7 +99,9 @@
车载监控
-
+
+ +
语音对话
@@ -118,6 +120,7 @@ import { getRobotList } from '@/api/inspection/robot' import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi } from '@/api/inspection/cockpit' import { Remove, CirclePlus } from '@element-plus/icons-vue' import VoiceConversation from "./VoiceConversation.vue"; +import IPlayer from "@/components/IPlayer/index.vue"; const robotList = ref([]) @@ -259,6 +262,8 @@ const stop = async () => { }) } +const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8') + onMounted(() => { fetchRobotList() }) diff --git a/src/views/inspection/cockpit/components/Monitor.vue b/src/views/inspection/cockpit/components/Monitor.vue index d404e90..b158262 100644 --- a/src/views/inspection/cockpit/components/Monitor.vue +++ b/src/views/inspection/cockpit/components/Monitor.vue @@ -20,7 +20,16 @@
-
监控
+
+
+
+ +
+
+
+ +
+
OCR识别
@@ -31,6 +40,9 @@