feat: 语音对话

This commit is contained in:
zhanghao 2026-07-14 13:49:37 +08:00
parent c53faef9fe
commit 4540dfb3d7
3 changed files with 989 additions and 2 deletions

View File

@ -17,7 +17,7 @@ const service = axios.create({
// axios中请求配置有baseURL选项表示请求URL公共部分
baseURL: import.meta.env.VITE_APP_BASE_API,
// 超时
timeout: 100000
timeout: 10000
})
// request拦截器
@ -117,7 +117,7 @@ service.interceptors.response.use(res => {
} else if (message.includes("Request failed with status code")) {
message = "系统接口" + message.substr(message.length - 3) + "异常";
}
ElMessage({ message: message, type: 'error', duration: 5 * 1000 })
ElMessage({ message: message, grouping: true, type: 'error', duration: 5 * 1000 })
return Promise.reject(error)
}
)

View File

@ -0,0 +1,966 @@
<template>
<section class="voice-conversation">
<main class="voice-content">
<header class="hero-title">
<p>低延迟实时语音对讲</p>
</header>
<div class="voice-visual" :class="{ active: connectionStatus === 'connected' }">
<div class="sound-wave sound-wave--left" aria-hidden="true">
<i v-for="index in 7" :key="`left-${index}`"></i>
</div>
<div class="microphone-orbit">
<div class="orbit-glow"></div>
<div class="microphone-core">
<svg viewBox="0 0 48 48" aria-label="麦克风" role="img">
<path d="M24 30a8 8 0 0 0 8-8V12a8 8 0 0 0-16 0v10a8 8 0 0 0 8 8Z" />
<path d="M11 21v2a13 13 0 0 0 26 0v-2M24 36v8M17 44h14" />
</svg>
</div>
</div>
<div class="sound-wave sound-wave--right" aria-hidden="true">
<i v-for="index in 7" :key="`right-${index}`"></i>
</div>
</div>
<div class="floating-status">
<span :class="['status-light', connectionStatus]"></span>
{{ statusMeta.label }}
</div>
<div class="call-controls">
<el-button
v-if="!isCalling"
class="call-button"
:loading="isStarting"
@click="startCall"
>
开始通话
</el-button>
<el-button v-else class="call-button call-button--danger" @click="endCall">
结束通话
</el-button>
<el-button
v-if="isCalling"
class="mute-button"
round
@click="toggleMute"
>
{{ isMuted ? '取消静音' : '静音' }}
</el-button>
</div>
<section class="status-card">
<div class="status-copy">
<p>当前状态<strong>{{ statusMeta.label }}</strong></p>
<span>{{ statusMeta.description }}</span>
</div>
<el-alert
v-if="errorMessage"
class="error-alert"
:title="errorMessage"
type="error"
:closable="true"
show-icon
@close="errorMessage = ''"
/>
<div class="feature-tags">
<span>高清语音</span>
<span>低延迟</span>
<span>加密传输</span>
</div>
</section>
</main>
<!-- 本地预览必须静音以免麦克风声音回放造成啸叫 -->
<audio ref="localAudio" autoplay muted playsinline></audio>
<audio ref="remoteAudio" autoplay playsinline></audio>
</section>
</template>
<script setup>
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 RTC_CONFIGURATION = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
}
// /WebRTC
const localAudio = ref(null)
const remoteAudio = ref(null)
const localStream = ref(null)
const remoteStream = ref(null)
const peerConnection = ref(null)
const webSocket = ref(null)
//
const isCalling = ref(false)
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
// SDP ICE addIceCandidate
let pendingIceCandidates = []
//
let callSessionId = 0
//
const statusMeta = computed(() => {
const statusMap = {
idle: { label: '未连接', description: '点击按钮建立语音通道' },
connecting: { label: '连接中', description: '正在建立安全语音通道,请稍候' },
connected: { label: '通话中', description: '语音通道已建立,可以开始对讲' },
error: { label: '未连接', description: '连接出现异常,请检查后重试' }
}
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 通知用户
* @param {string} message 展示给用户的错误信息
* @param {Error} [error] 原始异常对象主要用于控制台排查
*/
function showError(message, error) {
// 便
console.error(message, error || '')
// 便
errorMessage.value = message
//
connectionStatus.value = 'error'
// WebRTC
isConnected.value = false
// 使
ElNotification.error({ title: '语音对讲错误', message })
}
/**
* 通过已经连接的 WebSocket 发送一条信令消息
* OfferAnswer ICE 候选都通过此方法序列化为 JSON 后发送
* @param {Object} message 符合约定格式的信令消息
* @throws {Error} WebSocket 未处于 OPEN 状态时抛出异常
*/
function sendSignalingMessage(message) {
// WebSocket 访
const socket = webSocket.value
// WebSocket
if (!socket || socket.readyState !== WebSocket.OPEN) {
// Offer ICE
throw new Error('信令服务器尚未连接')
}
// JSON
socket.send(JSON.stringify(message))
}
/**
* 创建并初始化 RTCPeerConnection
* 作用包括加入本地麦克风轨道接收并播放远端音频发送 ICE 候选
* 以及监听连接状态来同步页面上的连接中/通话中/未连接状态
* @param {number} sessionId 本次通话的会话编号用于忽略过期回调
* @returns {RTCPeerConnection} 初始化完成的 WebRTC 连接实例
*/
function setupPeerConnection(sessionId) {
// 使 STUN WebRTC
const pc = new RTCPeerConnection(RTC_CONFIGURATION)
// 使
peerConnection.value = pc
//
remoteStream.value = new MediaStream()
// 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) => {
//
if (sessionId !== callSessionId) return
//
event.streams[0].getTracks().forEach((track) => {
// ID
const exists = remoteStream.value.getTracks().some((item) => item.id === track.id)
//
if (!exists) remoteStream.value.addTrack(track)
})
// autoplay
remoteAudio.value?.play().catch(() => {
//
ElMessage.warning('浏览器阻止了远端音频自动播放,请点击页面后重试')
})
}
// ICE
pc.onicecandidate = (event) => {
//
if (!event.candidate || sessionId !== callSessionId) return
try {
//
sendSignalingMessage({
type: 'ice-candidate',
candidate: event.candidate.candidate,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid
})
} catch (error) {
//
showError('ICE 候选发送失败', error)
}
}
// WebRTC UI
pc.onconnectionstatechange = () => {
//
if (sessionId !== callSessionId) return
// connected ICEDTLS
if (pc.connectionState === 'connected') {
// WebRTC
isConnected.value = true
//
connectionStatus.value = 'connected'
//
errorMessage.value = ''
//
ElMessage.success('实时语音连接已建立')
// failed disconnected
} else if (['failed', 'disconnected'].includes(pc.connectionState)) {
//
showError('WebRTC 连接失败或已断开')
}
}
// startCall Offer
return pc
}
/**
* 将暂存的远端 ICE 候选依次加入 WebRTC 连接
* 必须在远端 SDP 设置完成后调用否则部分浏览器会拒绝添加候选
* @param {RTCPeerConnection} pc 当前 WebRTC 连接
* @returns {Promise<void>}
*/
async function flushPendingIceCandidates(pc) {
//
const candidates = pendingIceCandidates.splice(0)
//
for (const candidate of candidates) {
//
await pc.addIceCandidate(candidate)
}
}
/**
* 处理信令服务器发来的消息
* - offer设置远端描述创建并返回 answer
* - answer完成发起端的远端描述设置
* - ice-candidate立即添加候选或等待远端 SDP 就绪后再添加
* @param {MessageEvent} event WebSocket 消息事件
* @param {number} sessionId 本次通话的会话编号
* @returns {Promise<void>}
*/
async function handleSignalingMessage(event, sessionId) {
// WebSocket
if (sessionId !== callSessionId) return
//
let message
try {
// JSON
message = JSON.parse(event.data)
} catch (error) {
// JSON
showError('收到的信令消息不是有效 JSON', error)
//
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') {
// 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') {
// 使 RTCIceCandidate
const candidate = new RTCIceCandidate({
candidate: message.candidate,
sdpMLineIndex: message.sdpMLineIndex,
sdpMid: message.sdpMid
})
// WebRTC
if (pc.remoteDescription) await pc.addIceCandidate(candidate)
// SDP
else pendingIceCandidates.push(candidate)
}
} catch (error) {
// SDP Answer
showError(`处理 ${message.type || '未知'} 信令失败`, error)
}
}
/**
* 建立与信令服务器的 WebSocket 连接并注册消息错误和关闭事件
* WebSocket 只负责交换连接信息实际语音数据建立连接后由 WebRTC 点对点传输
* @param {number} sessionId 本次通话的会话编号
* @returns {Promise<WebSocket>} 连接成功后返回 WebSocket 实例
*/
function connectSignalingServer(sessionId) {
// Promise startCall WebSocket Offer
return new Promise((resolve, reject) => {
// 使 WebSocket
const socket = new WebSocket(getSignalingUrl())
//
webSocket.value = socket
// Promise SDP
socket.onopen = () => resolve(socket)
// type
socket.onmessage = (event) => handleSignalingMessage(event, sessionId)
// Promise startCall
socket.onerror = () => reject(new Error('WebSocket 连接失败'))
//
socket.onclose = () => {
//
if (sessionId === callSessionId && isCalling.value) {
//
showError('信令服务器连接已断开')
}
}
})
}
/**
* 创建 Web Audio 音频分析器并持续读取麦克风频域数据
* 当前用于保留实时音量分析能力后续可将分析结果绑定到声波高度或音量条
* @param {MediaStream} stream 本地麦克风媒体流
*/
function startVolumeMeter(stream) {
// 使 AudioContext Safari
const AudioContextClass = window.AudioContext || window.webkitAudioContext
// Web Audio API
if (!AudioContextClass) return
//
audioContext = new AudioContextClass()
//
analyser = audioContext.createAnalyser()
// FFT
analyser.fftSize = 256
// Web Audio
audioContext.createMediaStreamSource(stream).connect(analyser)
//
const samples = new Uint8Array(analyser.frequencyBinCount)
//
const updateVolume = () => {
// samples
analyser.getByteFrequencyData(samples)
//
//
volumeAnimationFrame = requestAnimationFrame(updateVolume)
}
// requestAnimationFrame
updateVolume()
}
/**
* 开始一次实时语音通话
* 完整流程防止重复启动 请求麦克风权限 创建 WebRTC 连接
* 连接信令服务器 创建本地 Offer 发送 Offer 等待对端应答
* 任一步骤失败都会显示错误并释放本次已创建的所有资源
* @returns {Promise<void>}
*/
async function startCall() {
//
if (isCalling.value || isStarting.value || peerConnection.value) return
// 使
const sessionId = ++callSessionId
//
isStarting.value = true
// UI
connectionStatus.value = 'connecting'
//
errorMessage.value = ''
try {
//
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
//
echoCancellation: true,
//
noiseSuppression: true,
//
autoGainControl: true
},
//
video: false
})
//
if (sessionId !== callSessionId) {
//
stream.getTracks().forEach((track) => track.stop())
//
return
}
// WebRTC 使
localStream.value = stream
// audio
if (localAudio.value) localAudio.value.srcObject = stream
//
startVolumeMeter(stream)
// WebRTC
const pc = setupPeerConnection(sessionId)
// Offer
await connectSignalingServer(sessionId)
// SDP Offer
const offer = await pc.createOffer()
// ICE
await pc.setLocalDescription(offer)
// Offer
sendSignalingMessage({ type: 'offer', sdp: offer.sdp })
// connectionstatechange
isCalling.value = true
} catch (error) {
//
const permissionDenied = ['NotAllowedError', 'PermissionDeniedError'].includes(error.name)
//
showError(
permissionDenied
? '麦克风权限被拒绝,请在浏览器设置中允许访问'
: `开始通话失败:${error.message}`,
error
)
//
cleanupResources()
} finally {
//
isStarting.value = false
}
}
/**
* 切换本地麦克风的静音状态
* 通过修改音频轨道的 enabled 属性停止/恢复向对端发送声音媒体流本身不会被销毁
*/
function toggleMute() {
//
const audioTracks = localStream.value?.getAudioTracks() || []
//
if (!audioTracks.length) return
//
isMuted.value = !isMuted.value
// WebRTC
audioTracks.forEach((track) => {
//
track.enabled = !isMuted.value
})
//
ElMessage.info(isMuted.value ? '麦克风已静音' : '麦克风已取消静音')
}
/**
* 释放一次通话占用的全部资源
* 包括停止音量分析关闭 AudioContext/WebRTC/WebSocket停止本地和远端轨道
* 清空 audio 元素并重置通话相关状态该方法可重复调用供异常挂断和卸载共同使用
*/
function cleanupResources() {
// 使
callSessionId += 1
// ICE
pendingIceCandidates = []
//
if (volumeAnimationFrame) cancelAnimationFrame(volumeAnimationFrame)
//
volumeAnimationFrame = null
//
analyser?.disconnect()
// 便
analyser = null
// AudioContextcatch
audioContext?.close().catch(() => {})
//
audioContext = null
// WebRTC
if (peerConnection.value) {
//
peerConnection.value.ontrack = null
// ICE
peerConnection.value.onicecandidate = null
//
peerConnection.value.onconnectionstatechange = null
// ICEDTLS
peerConnection.value.close()
// WebRTC
peerConnection.value = null
}
// WebSocket便
const socket = webSocket.value
//
if (socket) {
//
socket.onopen = null
//
socket.onmessage = null
//
socket.onerror = null
//
socket.onclose = null
//
socket.close()
// WebSocket
webSocket.value = null
}
//
localStream.value?.getTracks().forEach((track) => track.stop())
//
remoteStream.value?.getTracks().forEach((track) => track.stop())
//
localStream.value = null
//
remoteStream.value = null
// audio
if (localAudio.value) localAudio.value.srcObject = null
// audio
if (remoteAudio.value) remoteAudio.value.srcObject = null
// 使
isCalling.value = false
// WebRTC
isConnected.value = false
//
isMuted.value = false
}
/**
* 主动结束当前通话
* 在统一清理资源后将页面恢复为未连接状态并向用户显示结束提示
*/
function endCall() {
//
cleanupResources()
//
connectionStatus.value = 'idle'
//
errorMessage.value = ''
//
ElMessage.success('通话已结束')
}
//
onBeforeUnmount(cleanupResources)
</script>
<style lang="scss" scoped>
$cyan: #46c8ff;
$blue: #2478ff;
$purple: #936cff;
$panel-border: rgb(112 192 255 / 24%);
.voice-conversation {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 400px;
max-height: 400px;
min-height: 0;
padding: 30px 28px 16px;
overflow: hidden;
box-sizing: border-box;
color: #fff;
background:
radial-gradient(circle at 50% 38%, rgb(44 90 198 / 34%) 0%, transparent 33%),
radial-gradient(circle at 75% 20%, rgb(81 49 172 / 18%) 0%, transparent 30%),
linear-gradient(145deg, #071a3e 0%, #08152f 45%, #050c21 100%);
&::before,
&::after {
position: absolute;
content: '';
pointer-events: none;
}
&::before {
inset: 0;
background: linear-gradient(90deg, rgb(32 143 255 / 7%) 1px, transparent 1px),
linear-gradient(rgb(32 143 255 / 7%) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, #000 12%, transparent 82%);
}
&::after {
width: 360px;
height: 360px;
border: 1px solid rgb(70 200 255 / 7%);
border-radius: 50%;
box-shadow: 0 0 100px rgb(36 120 255 / 8%);
}
.tech-grid {
position: absolute;
inset: 0;
background: repeating-linear-gradient(135deg, transparent 0 118px, rgb(89 138 255 / 3%) 119px 120px);
pointer-events: none;
}
.corner-title {
position: absolute;
top: 16px;
left: 22px;
z-index: 2;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
text-shadow: 0 0 18px rgb(74 168 255 / 30%);
}
.voice-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
width: min(100%, 660px);
}
.hero-title {
text-align: center;
h1 {
margin: 0;
font-size: clamp(23px, 3vw, 30px);
line-height: 1.2;
letter-spacing: 5px;
text-shadow: 0 0 25px rgb(79 159 255 / 25%);
}
p {
margin: 4px 0 0;
color: #86c6f4;
font-size: 12px;
letter-spacing: 3px;
}
}
.voice-visual {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(18px, 4vw, 42px);
width: 100%;
margin-top: 13px;
&.active {
.microphone-orbit { animation: orbit-breathe 2.4s ease-in-out infinite; }
.sound-wave i { animation-play-state: running; }
}
}
.microphone-orbit {
position: relative;
display: grid;
place-items: center;
flex: 0 0 auto;
width: 100px;
height: 100px;
border: 1px solid rgb(117 210 255 / 45%);
border-radius: 50%;
background: linear-gradient(145deg, rgb(41 146 255 / 20%), rgb(120 73 255 / 16%));
box-shadow: 0 0 26px rgb(35 158 255 / 35%), inset 0 0 28px rgb(117 83 255 / 24%);
&::before {
position: absolute;
inset: 7px;
border: 1px solid rgb(160 124 255 / 55%);
border-radius: inherit;
box-shadow: inset 0 0 16px rgb(55 186 255 / 22%);
content: '';
}
.orbit-glow {
position: absolute;
inset: -13px;
border-radius: inherit;
background: conic-gradient(from 90deg, transparent, rgb(58 190 255 / 35%), transparent, rgb(146 94 255 / 42%), transparent);
filter: blur(12px);
}
}
.microphone-core {
position: relative;
display: grid;
place-items: center;
width: 62px;
height: 62px;
border-radius: 50%;
background: linear-gradient(145deg, #237bfa, #794be1);
box-shadow: 0 8px 30px rgb(34 111 255 / 48%), inset 0 1px 1px rgb(255 255 255 / 35%);
svg {
width: 31px;
height: 31px;
overflow: visible;
fill: #fff;
stroke: #fff;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
path:first-child { stroke: none; }
path:last-child { fill: none; }
}
}
.sound-wave {
display: flex;
align-items: center;
gap: 6px;
width: 86px;
height: 60px;
i {
width: 4px;
height: var(--wave-height, 30px);
border-radius: 10px;
background: linear-gradient(to bottom, $cyan, $purple);
box-shadow: 0 0 10px rgb(72 190 255 / 46%);
opacity: 0.74;
animation: wave-pulse 1.15s ease-in-out infinite alternate;
animation-delay: calc(var(--index) * -0.09s);
animation-play-state: paused;
&:nth-child(1), &:nth-child(7) { --wave-height: 12px; --index: 1; opacity: 0.32; }
&:nth-child(2), &:nth-child(6) { --wave-height: 22px; --index: 2; opacity: 0.48; }
&:nth-child(3), &:nth-child(5) { --wave-height: 35px; --index: 3; opacity: 0.62; }
&:nth-child(4) { --wave-height: 48px; --index: 4; }
}
&--left { justify-content: flex-end; }
&--right { justify-content: flex-start; }
}
.floating-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
padding: 4px 13px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 20px;
color: #d5d9e4;
background: rgb(31 38 58 / 86%);
box-shadow: 0 7px 18px rgb(0 0 0 / 24%);
font-size: 11px;
.status-light {
width: 7px;
height: 7px;
border-radius: 50%;
background: #8791a7;
&.connecting { background: #ffd166; box-shadow: 0 0 8px #ffd166; }
&.connected { background: #4ef5ae; box-shadow: 0 0 8px #4ef5ae; }
&.error { background: #ff6482; box-shadow: 0 0 8px #ff6482; }
}
}
.call-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 34px;
margin-top: 9px;
:deep(.el-button + .el-button) { margin-left: 0; }
}
:deep(.call-button) {
width: 178px;
height: 34px;
border: 1px solid rgb(125 207 255 / 52%);
border-radius: 10px;
color: #fff;
background: linear-gradient(100deg, #176ee9, #448eff 56%, #7657e9);
box-shadow: 0 10px 28px rgb(24 105 235 / 35%), inset 0 1px rgb(255 255 255 / 18%);
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
&:hover,
&:focus {
color: #fff;
border-color: #86d5ff;
background: linear-gradient(100deg, #2280ff, #54a0ff 56%, #896afa);
transform: translateY(-1px);
}
&.call-button--danger {
border-color: rgb(255 126 157 / 55%);
background: linear-gradient(100deg, #cf3e67, #e4597e, #8554d9);
}
}
:deep(.mute-button) {
height: 30px;
border-color: $panel-border;
color: #badeff;
background: rgb(16 45 85 / 72%);
&:hover { color: #fff; border-color: $cyan; background: rgb(23 76 128 / 82%); }
}
.status-card {
width: min(100%, 550px);
margin-top: 20px;
padding: 8px 18px 8px;
border: 1px solid $panel-border;
border-radius: 16px;
box-sizing: border-box;
background: linear-gradient(135deg, rgb(14 49 91 / 67%), rgb(20 30 74 / 55%));
box-shadow: 0 16px 40px rgb(0 4 24 / 28%), inset 0 1px rgb(255 255 255 / 5%);
backdrop-filter: blur(16px);
.status-copy {
text-align: center;
p {
margin: 0;
color: #edf7ff;
font-size: 12px;
strong { color: #fff; font-weight: 600; }
}
span {
display: block;
margin-top: 2px;
color: #8eafd0;
font-size: 10px;
}
}
.error-alert { margin-top: 6px; }
}
.feature-tags {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 9px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgb(116 186 255 / 13%);
span {
padding: 2px 8px;
border: 1px solid rgb(87 183 255 / 35%);
border-radius: 20px;
color: #a9d6f8;
background: rgb(31 104 166 / 13%);
text-align: center;
font-size: 10px;
}
}
audio { display: none; }
}
@keyframes orbit-breathe {
0%, 100% { transform: scale(1); filter: brightness(1); }
50% { transform: scale(1.035); filter: brightness(1.16); }
}
@keyframes wave-pulse {
from { transform: scaleY(0.58); opacity: 0.42; }
to { transform: scaleY(1.08); opacity: 0.9; }
}
@media (max-width: 680px) {
.voice-conversation {
height: 400px;
max-height: 400px;
min-height: 0;
padding: 38px 14px 12px;
.corner-title { top: 12px; left: 16px; font-size: 17px; }
.voice-visual { gap: 10px; }
.microphone-orbit { width: 90px; height: 90px; }
.microphone-core { width: 56px; height: 56px; }
.microphone-core svg { width: 28px; height: 28px; }
.sound-wave { width: 58px; gap: 4px; }
.sound-wave i { width: 3px; }
.call-controls { flex-wrap: wrap; }
}
}
@media (max-width: 420px) {
.voice-conversation {
.hero-title p { letter-spacing: 1px; }
.voice-visual { gap: 7px; }
.sound-wave { width: 48px; gap: 3px; }
.sound-wave i:nth-child(1),
.sound-wave i:nth-child(7) { display: none; }
.feature-tags { gap: 7px; }
.status-card { padding-inline: 15px; }
}
}
</style>

View File

@ -102,6 +102,9 @@
<div class="monitor-container"></div>
<div class="split-line"></div>
<div class="container-title">语音对话</div>
<div class="voice-container">
<VoiceConversation />
</div>
</div>
</div>
</template>
@ -114,6 +117,7 @@ import RoboticArm from "./RoboticArm.vue";
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";
const robotList = ref([])
@ -191,6 +195,8 @@ const jointNamsMap = {
'joint_6': 'elfin_joint6',
}
const errCount = ref(0)
/**
* 获取关节状态
* @param robot
@ -210,9 +216,19 @@ const getJointState = async (robot) => {
})
}, 500)
} else {
errCount.value += 1
if (errCount.value > 2) {
clearInterval(animationId.value)
errCount.value = 0
}
console.error('获取关节状态失败:', res.message)
}
} catch (error) {
errCount.value += 1
if (errCount.value > 2) {
clearInterval(animationId.value)
errCount.value = 0
}
console.error('获取关节状态失败:', error)
}
}
@ -440,6 +456,11 @@ onUnmounted(() => {
width: 100%;
height: 360px;
}
.voice-container {
width: 100%;
height: 400px;
}
}
}