吉安感知网项目-前端
张含笑
2026-01-29 9fb4de853fc8aab2000f312ba2b8907e3dc9a61d
feat:调整
2 files modified
611 ■■■■ changed files
applications/drone-command/src/views/audioCall.vue 608 ●●●● patch | view | raw | blame | history
applications/task-work-order/src/views/orderView/orderDataManage/dataObjection/FormDiaLog.vue 3 ●●●● patch | view | raw | blame | history
applications/drone-command/src/views/audioCall.vue
@@ -1,439 +1,445 @@
<template>
    <div style="padding:16px">
      <h2>1v1 语音通话 WebRTC Demo(对齐后端 ChatMessage,JS)</h2>
      <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
        <span>我的 userId:</span>
        <input v-model="uid" style="width:120px" />
        <span>对方 userId:</span>
        <input v-model="peerUid" style="width:120px" />
        <button @click="connectWS" :disabled="connected">连接WS</button>
        <button @click="requestCall" :disabled="!connected || state!=='idle'">呼叫</button>
        <button v-if="state==='ringing'" @click="acceptCall">接听</button>
        <button v-if="state==='ringing'" @click="rejectCall">拒绝</button>
        <button @click="hangup" :disabled="!connected || (state!=='calling' && state!=='in-call')">挂断</button>
        <strong>状态:{{ state }}</strong>
        <strong v-if="state==='in-call'">通话时长:{{ durationText }}</strong>
      </div>
      <div style="margin-top:8px;color:#666">
        <small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>
      </div>
      <pre style="margin-top:12px;max-height:280px;overflow:auto;">{{ logText }}</pre>
      <audio ref="remoteAudio" autoplay></audio>
        <h2>1v1 语音通话 WebRTC Demo(对齐后端 ChatMessage,JS)</h2>
        <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
            <span>我的 userId:</span>
            <input v-model="uid" style="width:120px" />
            <span>对方 userId:</span>
            <input v-model="peerUid" style="width:120px" />
            <button @click="connectWS" :disabled="connected">连接WS</button>
            <button @click="requestCall" :disabled="!connected || state!=='idle'">呼叫</button>
            <button v-if="state==='ringing'" @click="acceptCall">接听</button>
            <button v-if="state==='ringing'" @click="rejectCall">拒绝</button>
            <button @click="hangup" :disabled="!connected || (state!=='calling' && state!=='in-call')">挂断</button>
            <strong>状态:{{ state }}</strong>
            <strong v-if="state==='in-call'">通话时长:{{ durationText }}</strong>
        </div>
        <div style="margin-top:8px;color:#666">
            <small>提示:若 getUserMedia 不可用,请用 https 或 localhost 访问页面。</small>
        </div>
        <pre style="margin-top:12px;max-height:280px;overflow:auto;">{{ logText }}</pre>
        <audio ref="remoteAudio" autoplay></audio>
    </div>
  </template>
  <script setup>
  import { ref, computed, onBeforeUnmount } from 'vue'
  /** ✅ 你的 WS 地址前缀(后面拼 userId) */
  const WS_BASE = 'wss://域名/ws/chat?userId='
  const uid = ref('2')
  const peerUid = ref('3')
  const connected = ref(false)
  const state = ref('idle') // idle | calling | ringing | in-call
  const logText = ref('')
  const remoteAudio = ref(null)
  let ws = null
  let pc = null
  let localStream = null
  const pendingCandidates = []
  // 来电暂存
  let incomingFrom = null
  let acceptedByMe = false // 我是否已经点了接听
  let offeredByPeer = false // 是否已收到对方 offer(用于流程判断)
  // 计时
  let timer = null
  const seconds = ref(0)
  const durationText = computed(() => {
</template>
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue'
/** ✅ 你的 WS 地址前缀(后面拼 userId) */
const WS_BASE = 'wss://wrj.shuixiongit.com/ws/chat?userId='
const uid = ref('2')
const peerUid = ref('3')
const connected = ref(false)
const state = ref('idle') // idle | calling | ringing | in-call
const logText = ref('')
const remoteAudio = ref(null)
let ws = null
let pc = null
let localStream = null
const pendingCandidates = []
// 来电暂存
let incomingFrom = null
let acceptedByMe = false // 我是否已经点了接听
let offeredByPeer = false // 是否已收到对方 offer(用于流程判断)
// 计时
let timer = null
const seconds = ref(0)
const durationText = computed(() => {
    const m = String(Math.floor(seconds.value / 60)).padStart(2, '0')
    const s = String(seconds.value % 60).padStart(2, '0')
    return `${m}:${s}`
  })
  function startTimer() {
})
function startTimer() {
    stopTimer()
    seconds.value = 0
    timer = setInterval(() => seconds.value++, 1000)
  }
  function stopTimer() {
}
function stopTimer() {
    if (timer) clearInterval(timer)
    timer = null
  }
  // 心跳
  let pingTimer = null
  function startPing() {
}
// 心跳
let pingTimer = null
function startPing() {
    stopPing()
    // 每 25 秒发一次 ping(你后端有 PING/PONG)
    pingTimer = setInterval(() => {
      if (connected.value) send('ping', 'system', null)
        if (connected.value) send('ping', 'system', null)
    }, 25000)
  }
  function stopPing() {
}
function stopPing() {
    if (pingTimer) clearInterval(pingTimer)
    pingTimer = null
  }
  function log(...args) {
}
function log(...args) {
    const line = args.map(v => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
    logText.value += `[${new Date().toLocaleTimeString()}] ${line}\n`
    console.log(...args)
  }
  /**
   * ✅ 严格按后端 ChatMessage 字段发:
   * { type, from, to, payload, timestamp }
   * 注意:后端会用 session 的 userId 覆盖 from,但我们带上也没坏处
   */
  function send(type, to, payload) {
}
/**
 * ✅ 严格按后端 ChatMessage 字段发:
 * { type, from, to, payload, timestamp }
 * 注意:后端会用 session 的 userId 覆盖 from,但我们带上也没坏处
 */
function send(type, to, payload) {
    if (!type) return
    if (!ws || ws.readyState !== WebSocket.OPEN) return
    const msg = {
      type,                 // "call/accept/busy/offer/answer/candidate/hangup/ping"
      from: String(uid.value),
      to: String(to),
      payload: payload ?? null,
      timestamp: Date.now(),
        type,                 // "call/accept/busy/offer/answer/candidate/hangup/ping"
        from: String(uid.value),
        to: String(to),
        payload: payload ?? null,
        timestamp: Date.now(),
    }
    ws.send(JSON.stringify(msg))
  }
  /* ---------------- WebRTC ---------------- */
  function createPC() {
}
/* ---------------- WebRTC ---------------- */
function createPC() {
    pc = new RTCPeerConnection({
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
        iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
    })
    pc.onicecandidate = (e) => {
      if (e.candidate) {
        send('candidate', peerUid.value, { candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate })
      }
        if (e.candidate) {
            send('candidate', peerUid.value, { candidate: e.candidate.toJSON ? e.candidate.toJSON() : e.candidate })
        }
    }
    pc.ontrack = (e) => {
      log('✅ 收到远端音频流')
      const stream = e.streams[0]
      if (remoteAudio.value) {
        remoteAudio.value.srcObject = stream
        remoteAudio.value.play().catch(() => {})
      }
        log('✅ 收到远端音频流')
        const stream = e.streams[0]
        if (remoteAudio.value) {
            remoteAudio.value.srcObject = stream
            remoteAudio.value.play().catch(() => {
            })
        }
    }
    pc.onconnectionstatechange = () => {
      log('pc.connectionState =', pc.connectionState)
        log('pc.connectionState =', pc.connectionState)
    }
  }
  async function getLocalAudio() {
}
async function getLocalAudio() {
    if (localStream) return localStream
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面')
      throw new Error('getUserMedia not available')
        log('❌ 当前环境不支持 getUserMedia:请使用 https 或 localhost 打开页面')
        throw new Error('getUserMedia not available')
    }
    try {
      localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
      log('✅ 已获取本地麦克风')
      return localStream
        localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
        log('✅ 已获取本地麦克风')
        return localStream
    } catch (e) {
      log('❌ 获取麦克风失败:', String(e))
      throw e
        log('❌ 获取麦克风失败:', String(e))
        throw e
    }
  }
  /** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */
  async function ensureLocalTracksAdded() {
}
/** ✅ 关键:只 addTrack 一次,避免 “sender already exists” */
async function ensureLocalTracksAdded() {
    if (!pc) createPC()
    const stream = await getLocalAudio()
    const existingTrackIds = new Set(
      pc.getSenders().map(s => (s.track ? s.track.id : null)).filter(Boolean)
        pc.getSenders().map(s => (s.track ? s.track.id : null)).filter(Boolean)
    )
    stream.getTracks().forEach(track => {
      if (!existingTrackIds.has(track.id)) {
        pc.addTrack(track, stream)
        log('addTrack ok:', track.id)
      } else {
        // log('skip addTrack:', track.id)
      }
        if (!existingTrackIds.has(track.id)) {
            pc.addTrack(track, stream)
            log('addTrack ok:', track.id)
        } else {
            // log('skip addTrack:', track.id)
        }
    })
    return stream
  }
  async function flushCandidates() {
}
async function flushCandidates() {
    if (!pc || !pc.remoteDescription) return
    while (pendingCandidates.length) {
      const c = pendingCandidates.shift()
      try {
        await pc.addIceCandidate(new RTCIceCandidate(c))
      } catch (e) {
        log('addIceCandidate failed:', String(e))
      }
        const c = pendingCandidates.shift()
        try {
            await pc.addIceCandidate(new RTCIceCandidate(c))
        } catch (e) {
            log('addIceCandidate failed:', String(e))
        }
    }
  }
  /* ---------------- 信令处理 ---------------- */
  async function onSignal(msg) {
}
/* ---------------- 信令处理 ---------------- */
async function onSignal(msg) {
    if (!msg) return
    const t = (msg.type || '').toString()
    // ✅ 忽略后端回显给自己的消息(你的后端会回显 sender)
    if (msg.from && String(msg.from) === String(uid.value)) {
      return
        return
    }
    // system 消息:login/pong/busy 仅记录
    if (msg.from === 'system') {
      log('system msg:', msg)
      return
    }
    if (!t) return
    if (t === 'call') {
      // 收到来电:弹接听按钮
      incomingFrom = String(msg.from)
      peerUid.value = incomingFrom
      acceptedByMe = false
      offeredByPeer = false
      state.value = 'ringing'
      log('📞 收到来电 call,来自', incomingFrom)
      return
    }
    if (t === 'accept') {
      // 主叫收到 accept:开始 offer
      log('✅ 对方接听 accept,开始建立连接(offer)')
      await startOffer()
      return
    }
    if (t === 'busy') {
      log('❌ 对方忙线/拒绝')
      cleanup(false)
      return
    }
    if (t === 'offer') {
      // 被叫收到 offer:只有点击接听后才真正应答(你要求“先点接听”)
      offeredByPeer = true
      incomingFrom = String(msg.from)
      peerUid.value = incomingFrom
      if (!acceptedByMe) {
        state.value = 'ringing'
        log('📩 收到 offer(但未接听),已等待用户点击接听')
        // 暂存 offer 到一个变量
        lastOfferSdp = msg.payload?.sdp
        log('system msg:', msg)
        return
      }
      // 已接听:直接 answer
      await answerOffer(msg.payload?.sdp, incomingFrom)
      return
    }
    if (!t) return
    if (t === 'call') {
        // 收到来电:弹接听按钮
        incomingFrom = String(msg.from)
        peerUid.value = incomingFrom
        acceptedByMe = false
        offeredByPeer = false
        state.value = 'ringing'
        log('📞 收到来电 call,来自', incomingFrom)
        return
    }
    if (t === 'accept') {
        // 主叫收到 accept:开始 offer
        log('✅ 对方接听 accept,开始建立连接(offer)')
        await startOffer()
        return
    }
    if (t === 'busy') {
        log('❌ 对方忙线/拒绝')
        cleanup(false)
        return
    }
    if (t === 'offer') {
        // 被叫收到 offer:只有点击接听后才真正应答(你要求“先点接听”)
        offeredByPeer = true
        incomingFrom = String(msg.from)
        peerUid.value = incomingFrom
        if (!acceptedByMe) {
            state.value = 'ringing'
            log('📩 收到 offer(但未接听),已等待用户点击接听')
            // 暂存 offer 到一个变量
            lastOfferSdp = msg.payload?.sdp
            return
        }
        // 已接听:直接 answer
        await answerOffer(msg.payload?.sdp, incomingFrom)
        return
    }
    if (t === 'answer') {
      log('✅ 收到 answer,通话建立')
      if (!pc) createPC()
      await pc.setRemoteDescription(new RTCSessionDescription(msg.payload?.sdp))
      await flushCandidates()
      state.value = 'in-call'
      startTimer()
      return
        log('✅ 收到 answer,通话建立')
        if (!pc) createPC()
        await pc.setRemoteDescription(new RTCSessionDescription(msg.payload?.sdp))
        await flushCandidates()
        state.value = 'in-call'
        startTimer()
        return
    }
    if (t === 'candidate') {
      const c = msg.payload?.candidate
      if (!c) return
      if (pc && pc.remoteDescription) {
        await pc.addIceCandidate(new RTCIceCandidate(c))
      } else {
        pendingCandidates.push(c)
      }
      return
        const c = msg.payload?.candidate
        if (!c) return
        if (pc && pc.remoteDescription) {
            await pc.addIceCandidate(new RTCIceCandidate(c))
        } else {
            pendingCandidates.push(c)
        }
        return
    }
    if (t === 'hangup') {
      log('📴 对方挂断')
      cleanup(false)
      return
        log('📴 对方挂断')
        cleanup(false)
        return
    }
    if (t === 'pong') {
      // 心跳响应
      // log('pong')
      return
        // 心跳响应
        // log('pong')
        return
    }
  }
  // 暂存 offer(用于“先点接听再 answer”)
  let lastOfferSdp = null
  /* ---------------- 业务操作 ---------------- */
  function connectWS() {
}
// 暂存 offer(用于“先点接听再 answer”)
let lastOfferSdp = null
/* ---------------- 业务操作 ---------------- */
function connectWS() {
    if (ws) ws.close()
    ws = new WebSocket(WS_BASE + encodeURIComponent(uid.value))
    ws.onopen = () => {
      connected.value = true
      log('WS 已连接 userId=', uid.value)
      startPing()
        connected.value = true
        log('WS 已连接 userId=', uid.value)
        startPing()
    }
    ws.onclose = () => {
      connected.value = false
      log('WS 已关闭')
      stopPing()
        connected.value = false
        log('WS 已关闭')
        stopPing()
    }
    ws.onerror = (e) => log('WS error', e)
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data)
      log('收到信令', msg)
      onSignal(msg)
        const msg = JSON.parse(e.data)
        log('收到信令', msg)
        onSignal(msg)
    }
  }
  function requestCall() {
}
function requestCall() {
    state.value = 'calling'
    incomingFrom = null
    acceptedByMe = false
    offeredByPeer = false
    lastOfferSdp = null
    send('call', peerUid.value, null)
    log('➡️ 发起呼叫 call 给', peerUid.value)
  }
  function acceptCall() {
}
function acceptCall() {
    if (!incomingFrom) return
    acceptedByMe = true
    // 先告诉对方我接听了(后端会把双方置忙)
    send('accept', incomingFrom, null)
    log('✅ 已点击接听,发送 accept 给', incomingFrom)
    state.value = 'calling'
    // 如果 offer 已经提前到达(我们暂存了),立刻 answer
    if (lastOfferSdp) {
      answerOffer(lastOfferSdp, incomingFrom)
      lastOfferSdp = null
        answerOffer(lastOfferSdp, incomingFrom)
        lastOfferSdp = null
    }
  }
  function rejectCall() {
}
function rejectCall() {
    if (!incomingFrom) return
    // 你后端没有 reject,用 busy 表示拒绝/不可接听
    send('busy', incomingFrom, null)
    log('❌ 已拒绝来电,发送 busy 给', incomingFrom)
    incomingFrom = null
    acceptedByMe = false
    offeredByPeer = false
    lastOfferSdp = null
    state.value = 'idle'
  }
  async function startOffer() {
}
async function startOffer() {
    if (!pc) createPC()
    // ✅ 不会重复 addTrack
    await ensureLocalTracksAdded()
    const offer = await pc.createOffer()
    await pc.setLocalDescription(offer)
    send('offer', peerUid.value, { sdp: pc.localDescription })
    log('➡️ 已发送 offer 给', peerUid.value)
  }
  async function answerOffer(offerSdp, from) {
}
async function answerOffer(offerSdp, from) {
    if (!offerSdp) {
      log('❌ offer sdp 为空,无法接听')
      return
        log('❌ offer sdp 为空,无法接听')
        return
    }
    if (!pc) createPC()
    await pc.setRemoteDescription(new RTCSessionDescription(offerSdp))
    await flushCandidates()
    await ensureLocalTracksAdded()
    const answer = await pc.createAnswer()
    await pc.setLocalDescription(answer)
    send('answer', from, { sdp: pc.localDescription })
    log('⬅️ 已发送 answer 给', from)
    state.value = 'in-call'
    startTimer()
  }
  function hangup() {
}
function hangup() {
    cleanup(true)
  }
  /* ---------------- 清理 ---------------- */
  function cleanup(sendToPeer) {
}
/* ---------------- 清理 ---------------- */
function cleanup(sendToPeer) {
    try {
      if (sendToPeer) send('hangup', peerUid.value, null)
    } catch {}
        if (sendToPeer) send('hangup', peerUid.value, null)
    } catch {
    }
    stopTimer()
    if (pc) {
      pc.getSenders().forEach(s => s.track && s.track.stop())
      pc.close()
        pc.getSenders().forEach(s => s.track && s.track.stop())
        pc.close()
    }
    if (localStream) {
      localStream.getTracks().forEach(t => t.stop())
        localStream.getTracks().forEach(t => t.stop())
    }
    pc = null
    localStream = null
    pendingCandidates.length = 0
    incomingFrom = null
    acceptedByMe = false
    offeredByPeer = false
    lastOfferSdp = null
    state.value = 'idle'
  }
  onBeforeUnmount(() => {
}
onBeforeUnmount(() => {
    cleanup(false)
    stopPing()
    if (ws) ws.close()
  })
  </script>
})
</script>
  
applications/task-work-order/src/views/orderView/orderDataManage/dataObjection/FormDiaLog.vue
@@ -292,7 +292,7 @@
    catalogResourceName: fieldRules(true),
    objectionDesc: fieldRules(true),
    objectionBasis: fieldRules(true),
    // attachId: dialogMode.value === 'add' ? fieldRules(true) : fieldRules(false)
    attachId: fieldRules(true)
}
@@ -375,6 +375,7 @@
// 提交数据的通用函数
async function submitForm(objectionStatus) {
formData.value.attachId = uploadedFiles.value.length > 0 ? uploadedFiles.value.length : ''
  const isValid = await formRef.value?.validate().catch(() => false)
  if (!isValid) return