<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>
        
        <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 = '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' }]
}

// 音频元素及通话核心资源：分别保存本地/远端媒体流、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')

// 音量分析和异步通话过程使用的非响应式资源。
let audioContext = null
let analyser = null
let volumeAnimationFrame = null
// 后端返回 joined 后生成的业务会话 ID，后续 Offer、Candidate、心跳和停止消息都必须携带。
let signalingSessionId = ''
// 远端 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
})

/**
 * 统一处理通话过程中的异常。
 * 该方法会记录错误、更新页面状态，并使用 Element Plus 通知用户。
 * @param {string} message 展示给用户的错误信息
 * @param {Error} [error] 原始异常对象，主要用于控制台排查
 */
function showError(message, error) {
  // 将完整错误输出到控制台，方便开发环境定位具体异常。
  console.error(message, error || '')
  // 将内部状态切换为错误状态，驱动状态灯和说明文字更新。
  connectionStatus.value = 'error'
  // 明确标记当前已经不处于 WebRTC 已连接状态。
  isConnected.value = false
  // 使用全局通知主动提醒用户本次通话发生异常。
  ElNotification.error({ title: '语音对讲错误', message })
}

/**
 * 通过已经连接的 WebSocket 发送一条信令消息。
 * Offer、Answer 和 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

  // 对端音频轨道到达后，将轨道加入远端媒体流并交给 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: 'candidate',
        sessionId: signalingSessionId,
        candidate: {
          candidate: event.candidate.candidate,
          sdpMid: event.candidate.sdpMid,
          sdpMLineIndex: event.candidate.sdpMLineIndex
        }
      })
    } catch (error) {
      // 信令连接异常时进入统一错误处理流程。
      showError('ICE 候选发送失败', error)
    }
  }

  // WebRTC 连接状态变化时更新 UI，并对连接失败或断开进行统一报错。
  pc.onconnectionstatechange = () => {
    // 忽略已经被清理的旧会话触发的状态变化。
    if (sessionId !== callSessionId) return
    // connected 表示 ICE、DTLS 和媒体通道均已成功建立。
    if (pc.connectionState === 'connected') {
      // 标记 WebRTC 已连接，供业务逻辑判断当前通话状态。
      isConnected.value = true
      // 切换为通话中状态，启用页面声波和光晕动画。
      connectionStatus.value = 'connected'
      // 向用户提示语音通道已经成功建立。
      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)
  }
}

/**
 * 处理信令服务器发来的消息。
 * - answer：完成发起端的远端描述设置；
 * - 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
  }

  // 后端消息携带其他会话 ID 时直接忽略，防止串话或旧会话消息污染当前连接。
  if (message.sessionId && signalingSessionId && message.sessionId !== signalingSessionId) return

  // 获取当前 WebRTC 实例，后续 SDP 和 ICE 都需要写入该实例。
  const pc = peerConnection.value
  // 通话可能已经被清理，没有连接实例时直接忽略消息。
  if (!pc) return

  try {
    // 当前协议固定由浏览器发送 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 === 'candidate') {
      // 使用协议字段还原浏览器可识别的 RTCIceCandidate 对象。
      const candidate = new RTCIceCandidate({
        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 创建或候选添加过程中的所有异常。
    showError(`处理 ${message.type || '未知'} 信令失败`, error)
  }
}

/**
 * 建立与信令服务器的 WebSocket 连接并注册消息、错误和关闭事件。
 * WebSocket 只负责交换连接信息，实际语音数据建立连接后由 WebRTC 点对点传输。
 * @param {number} sessionId 本次通话的会话编号
 * @returns {Promise<WebSocket>} 连接成功后返回 WebSocket 实例
 */
function connectSignalingServer(sessionId) {
  // Promise 只有在后端返回 joined 后才完成，确保 Offer 一定携带有效 sessionId。
  return new Promise((resolve, reject) => {
    // 直接连接后端提供的语音信令地址。
    const socket = new WebSocket(SIGNALING_SERVER_URL)
    // 保存实例，供发送信令以及通话结束时关闭连接。
    webSocket.value = socket
    // 标记 joined 是否已经返回，用于区分建连失败和通话中断线。
    let hasJoined = false

    // 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 (!hasJoined) {
        reject(new Error('信令服务器在会话建立前已断开'))
      } else 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'

  try {
    // 第一步连接信令服务器、发送 join，并等待后端返回 joined 和业务 sessionId。
    await connectSignalingServer(sessionId)
    // joined 成功后创建 WebRTC 连接，用于承载后续双向音频轨道。
    const pc = setupPeerConnection(sessionId)

    // 请求用户授权并采集仅包含音频的本地媒体流。
    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；连接建立后浏览器会自动通过 WebRTC 发送音频。
    stream.getAudioTracks().forEach((track) => {
      pc.addTrack(track, stream)
    })

    // 根据本地音频能力生成发起协商所需的 SDP Offer。
    const offer = await pc.createOffer({ offerToReceiveAudio: true })
    // 设置本地描述，正式启动 ICE 候选收集过程。
    await pc.setLocalDescription(offer)
    // 按约定协议将 Offer 发送给同一房间的远端用户。
    sendSignalingMessage({ type: 'offer', sessionId: signalingSessionId, 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(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 候选，防止带入下一次通话。
  pendingIceCandidates = []

  // 停止浏览器逐帧执行音量分析循环。
  if (volumeAnimationFrame) cancelAnimationFrame(volumeAnimationFrame)
  // 清除动画帧编号，确保状态与实际资源一致。
  volumeAnimationFrame = null
  // 断开分析节点与音频节点之间的连接。
  analyser?.disconnect()
  // 释放分析器引用，便于垃圾回收。
  analyser = null
  // 关闭 AudioContext；catch 用于忽略已经关闭导致的重复清理异常。
  audioContext?.close().catch(() => {})
  // 释放音频上下文引用。
  audioContext = null

  // 仅在 WebRTC 实例存在时执行事件解绑和关闭操作。
  if (peerConnection.value) {
    // 解除远端轨道监听，避免关闭过程中继续处理事件。
    peerConnection.value.ontrack = null
    // 解除 ICE 候选监听，避免关闭后继续发送信令。
    peerConnection.value.onicecandidate = null
    // 解除连接状态监听，避免主动关闭被误判为异常断开。
    peerConnection.value.onconnectionstatechange = null
    // 关闭底层 ICE、DTLS 和媒体传输连接。
    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
  // 清空后端业务会话 ID，避免下一次通话误用旧 sessionId。
  signalingSessionId = ''
}

/**
 * 主动结束当前通话。
 * 在统一清理资源后，将页面恢复为“未连接”状态并向用户显示结束提示。
 */
function endCall() {
  // 统一关闭连接并释放麦克风、媒体流和音频分析资源。
  cleanupResources()
  // 将状态恢复为未连接，更新状态标签和说明文案。
  connectionStatus.value = 'idle'
  // 主动结束后清除错误信息，避免旧错误继续显示。
  // 向用户确认通话已经结束。
  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>
