diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue index 35117bf..f0a3e19 100644 --- a/frontend/src/pages/cloud-law/video-call.vue +++ b/frontend/src/pages/cloud-law/video-call.vue @@ -166,7 +166,8 @@ import { minimizeCloudLawMeeting } from '@/utils/cloudLawMiniProgram' import { notifyCloudLawBackendEnd, notifyCloudLawParentCallEnded, - isCloudLawPcIframe + isCloudLawPcIframe, + notifyNativeMiniProgramHangup } from '@/utils/cloudLawVoiceCall' import bgImage from '@/static/bg.png' import hangupIcon from '@/static/cacel.png' @@ -1017,6 +1018,10 @@ const hangup = async () => { userInitiatedHangup.value = true leaving.value = true stopCommandPolling() + if (!isLawRole.value) { + // 优先触发小程序原生 /end(web-view 跨域直连云律 API 不可靠) + notifyNativeMiniProgramHangup() + } try { // 先通知云律后端结束本次通话: // - 用户端:/api/applet/voiceConnection/{callId}/end diff --git a/frontend/src/pages/meeting/room.vue b/frontend/src/pages/meeting/room.vue index 82e7071..e6bf94b 100644 --- a/frontend/src/pages/meeting/room.vue +++ b/frontend/src/pages/meeting/room.vue @@ -242,7 +242,8 @@ import { isCloudLawLawRoleInMeeting, isCloudLawPcIframe, notifyCloudLawBackendEnd, - notifyCloudLawParentCallEnded + notifyCloudLawParentCallEnded, + queryCloudLawCallStatus } from '@/utils/cloudLawVoiceCall' const meetingStore = useMeetingStore() @@ -843,6 +844,82 @@ const syncViewportHeight = () => { // #endif } +let cloudLawStatusPollTimer = null +let cloudLawParticipantStopWatch = null +let cloudLawSoloCheckTimer = null +let cloudLawConsultEnding = false +const hadRemoteParticipant = ref(false) + +const getMyUserId = () => userStore.userInfo?.id || userStore.userInfo?.user_id + +const countRemoteActiveParticipants = () => { + const myUid = getMyUserId() + return meetingStore.activeParticipants.filter(p => String(p.user_id) !== String(myUid)).length +} + +const stopCloudLawStatusPoll = () => { + if (cloudLawStatusPollTimer) { + clearInterval(cloudLawStatusPollTimer) + cloudLawStatusPollTimer = null + } +} + +const handleCloudLawConsultEnded = async (tip = '用户已退出咨询') => { + if (cloudLawConsultEnding || !isCloudLawLawRoleInMeeting(cloudLawRole.value)) return + cloudLawConsultEnding = true + stopCloudLawStatusPoll() + if (cloudLawSoloCheckTimer) { + clearTimeout(cloudLawSoloCheckTimer) + cloudLawSoloCheckTimer = null + } + try { + if (cloudLawCallId.value) { + await notifyCloudLawBackendEnd(cloudLawCallId.value, 'law', cloudLawApiBase.value) + notifyCloudLawParentCallEnded(cloudLawCallId.value) + } + if (meetingStore.isInMeeting) { + await meetingStore.leave().catch(() => {}) + } + uni.showToast({ title: tip, icon: 'none' }) + if (!isCloudLawPcIframe()) { + setTimeout(() => redirectHome(true), 800) + } + } finally { + cloudLawConsultEnding = false + } +} + +const pollCloudLawCallStatusOnce = async () => { + if (!isCloudLawLawRoleInMeeting(cloudLawRole.value) || !cloudLawCallId.value || cloudLawConsultEnding) return + if (meetingStore.localState === MEETING_LOCAL_STATE_ENDED || meetingStore.localState === MEETING_LOCAL_STATE_LEAVING) return + const data = await queryCloudLawCallStatus(cloudLawCallId.value, 'law', cloudLawApiBase.value) + const status = String(data?.status || data?.callStatus || '').toLowerCase() + if (['ended', 'cancelled', 'failed'].includes(status)) { + await handleCloudLawConsultEnded('咨询已结束') + } +} + +const startCloudLawStatusPoll = () => { + if (!isCloudLawLawRoleInMeeting(cloudLawRole.value) || !cloudLawCallId.value) return + stopCloudLawStatusPoll() + pollCloudLawCallStatusOnce() + cloudLawStatusPollTimer = setInterval(pollCloudLawCallStatusOnce, 1000) +} + +const scheduleCloudLawSoloCheck = () => { + if (cloudLawSoloCheckTimer) { + clearTimeout(cloudLawSoloCheckTimer) + } + cloudLawSoloCheckTimer = setTimeout(async () => { + cloudLawSoloCheckTimer = null + if (!hadRemoteParticipant.value || cloudLawConsultEnding) return + if (meetingStore.localState !== MEETING_LOCAL_STATE_CONNECTED) return + if (countRemoteActiveParticipants() === 0) { + await handleCloudLawConsultEnded('用户已退出咨询') + } + }, 500) +} + // ============ 生命周期 ============ let stateStopWatch = null @@ -919,6 +996,25 @@ onMounted(() => { } }) + // 云律律师端:轮询通话状态 + 对方离会后自动收尾(用户挂断不一定能推到 room.ended) + if (isCloudLawLawRoleInMeeting(cloudLawRole.value)) { + startCloudLawStatusPoll() + cloudLawParticipantStopWatch = watch( + () => meetingStore.activeParticipants, + () => { + const remoteCount = countRemoteActiveParticipants() + if (remoteCount > 0) { + hadRemoteParticipant.value = true + return + } + if (hadRemoteParticipant.value && remoteCount === 0) { + scheduleCloudLawSoloCheck() + } + }, + { deep: true } + ) + } + // #ifdef H5 if (showCloudLawBack.value) { try { @@ -941,6 +1037,15 @@ onBeforeUnmount(() => { } if (viewportCleanup) { viewportCleanup(); viewportCleanup = null } if (stateStopWatch) { stateStopWatch(); stateStopWatch = null } + stopCloudLawStatusPoll() + if (cloudLawParticipantStopWatch) { + cloudLawParticipantStopWatch() + cloudLawParticipantStopWatch = null + } + if (cloudLawSoloCheckTimer) { + clearTimeout(cloudLawSoloCheckTimer) + cloudLawSoloCheckTimer = null + } // #ifdef H5 if (popstateHandler) { window.removeEventListener('popstate', popstateHandler) diff --git a/frontend/src/utils/cloudLawVoiceCall.js b/frontend/src/utils/cloudLawVoiceCall.js index 01e03e3..eee7b14 100644 --- a/frontend/src/utils/cloudLawVoiceCall.js +++ b/frontend/src/utils/cloudLawVoiceCall.js @@ -70,3 +70,54 @@ export function isCloudLawLawRoleInMeeting(role) { export function isCloudLawPcIframe() { return typeof window !== 'undefined' && !!(window.parent && window.parent !== window) } + +/** 查询云律通话状态(律师端 /api/law/voiceCall,用户端 /api/applet/voiceConnection) */ +export function queryCloudLawCallStatus(callId, role = 'law', apiBase = '', token = '') { + if (!callId) return Promise.resolve(null) + const endBase = String(role).toLowerCase() === 'law' ? '/api/law/voiceCall' : '/api/applet/voiceConnection' + const url = resolveCloudLawApiUrl(`${endBase}/${encodeURIComponent(callId)}/status`, apiBase) + const header = { 'Content-Type': 'application/json' } + if (token) { + header.token = token.startsWith('Bearer') ? token : `Bearer ${token}` + } + return new Promise(resolve => { + uni.request({ + url, + method: 'GET', + header, + timeout: 8000, + success: (res) => { + const body = res?.data || {} + const payload = body.data !== undefined ? body.data : body + resolve(payload && typeof payload === 'object' ? payload : null) + }, + fail: () => resolve(null) + }) + }) +} + +const CLOUD_LAW_HANGUP_QUERY = 'cloudLawHangup' + +/** 小程序 web-view 内挂断:改 URL 触发原生页 @load,立即调 /end(不依赖 web-view 销毁) */ +export function notifyNativeMiniProgramHangup() { + // #ifdef H5 + if (typeof window === 'undefined') return false + try { + const mp = window.wx && window.wx.miniProgram + if (!mp) return false + const href = String(window.location.href || '') + if (href.includes(`${CLOUD_LAW_HANGUP_QUERY}=1`)) return true + const url = new URL(href) + url.searchParams.set(CLOUD_LAW_HANGUP_QUERY, '1') + window.location.replace(url.toString()) + return true + } catch (e) { + console.warn('[cloudLawVoiceCall] native hangup bridge failed', e) + } + // #endif + return false +} + +export function isCloudLawHangupBridgeUrl(url) { + return String(url || '').includes(`${CLOUD_LAW_HANGUP_QUERY}=1`) +}