电话咨询

This commit is contained in:
duoaohui
2026-06-04 14:32:52 +08:00
parent edda31e8bf
commit b89b930c41
4 changed files with 134 additions and 20 deletions

View File

@@ -169,6 +169,9 @@ const consultId = ref('')
const miniProgramReturnUrl = ref('')
const cloudLawApiBase = ref('')
const cloudLawToken = ref('')
// 调用方角色:'law' = 律师端(接单端),其余/空 = 用户端applet
// 律师为参会者(非主持人),结束会议走 /api/law/voiceCall/{callId}/end用户端默认 /api/applet/voiceConnection。
const cloudLawRole = ref('')
const callMode = ref('video')
const joining = ref(false)
const joinError = ref('')
@@ -209,6 +212,9 @@ const audioEnabled = computed(() => !!meetingStore.localAudioEnabled)
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
const speakingMap = computed(() => meetingStore.speakingMap || {})
const isVoiceMode = computed(() => callMode.value === 'voice')
const isLawRole = computed(() => String(cloudLawRole.value).toLowerCase() === 'law')
// 结束会议接口基址:律师端 /api/law/voiceCall用户端 /api/applet/voiceConnection
const voiceEndBase = computed(() => isLawRole.value ? '/api/law/voiceCall' : '/api/applet/voiceConnection')
const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon)
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon)
@@ -403,7 +409,7 @@ const notifyCloudLawEnd = () => {
if (!callId.value || !cloudLawApiBase.value) return Promise.resolve()
return new Promise(resolve => {
uni.request({
url: resolveCloudLawApiUrl(`/api/applet/voiceConnection/${encodeURIComponent(callId.value)}/end`),
url: resolveCloudLawApiUrl(`${voiceEndBase.value}/${encodeURIComponent(callId.value)}/end`),
method: 'POST',
data: {},
header: cloudLawToken.value ? { 'Content-Type': 'application/json', token: `Bearer ${cloudLawToken.value}` } : { 'Content-Type': 'application/json' },
@@ -499,6 +505,8 @@ const pollCommandsOnce = async () => {
}
const startCommandPolling = () => {
// 律师端(接单端)无 publicCommands 远程指令通道,跳过轮询避免无谓 404
if (isLawRole.value) return
if (!callId.value || commandPollHandle) return
pollCommandsOnce()
commandPollHandle = setInterval(pollCommandsOnce, 1200)
@@ -840,21 +848,34 @@ const hangup = async () => {
leaving.value = true
stopCommandPolling()
try {
// 先通知云律后端结束本次通话:
// - 用户端:/api/applet/voiceConnection/{callId}/end
// - 律师端:/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议(双方都收到 room.ended
await notifyCloudLawEnd()
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。
// 不依赖 meetingStore.isHosthost_id/userId 偶发类型不一致导致误判 false 只走 leave
// 表现就是"用户端挂机后 PC 还停在会议里")。
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
roomCode: meetingStore.currentRoom?.room_code,
hostId: meetingStore.currentRoom?.host_id,
myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id,
isHostComputed: meetingStore.isHost
})
await meetingStore.endMeeting()
if (isLawRole.value) {
// 律师为参会者非主持人EndRoom 是 host-only。会议已由上面的 /end 在后端结束,
// 这里仅本地离会即可(直接 endMeeting 会因非主持人 403徒增一次失败请求
console.log('[CloudLawVideoCall] hangup(law) -> backend end + local leave', {
roomCode: meetingStore.currentRoom?.room_code,
callId: callId.value
})
await meetingStore.leave().catch(() => {})
} else {
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师端收到 room.ended 自动退出。
// 不依赖 meetingStore.isHosthost_id/userId 偶发类型不一致导致误判 false 只走 leave
// 表现就是"用户端挂机后 PC 还停在会议里")。
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
roomCode: meetingStore.currentRoom?.room_code,
hostId: meetingStore.currentRoom?.host_id,
myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id,
isHostComputed: meetingStore.isHost
})
await meetingStore.endMeeting()
}
} catch (err) {
console.warn('[CloudLawVideoCall] endMeeting failed, fallback leave', err)
// endMeeting 失败时兜底 leave至少自己先退出避免卡死
console.warn('[CloudLawVideoCall] end/hangup failed, fallback leave', err)
// 失败时兜底 leave至少自己先退出避免卡死
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
} finally {
ended.value = true
@@ -871,6 +892,7 @@ onLoad(query => {
miniProgramReturnUrl.value = query?.miniProgramReturnUrl || query?.mini_program_return_url || ''
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
cloudLawToken.value = query?.cloudLawToken || query?.cloud_law_token || ''
cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase()
callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
joinMeeting()

View File

@@ -184,6 +184,65 @@ class WebSocketService {
})
}
/**
* 当前连接是否处于可发送状态H5 下要求 readyState === OPEN
* 与 send() 的就绪判断保持一致
* @returns {boolean}
*/
isConnected() {
// #ifdef H5
return !!(this.ws && this.ws.readyState === WebSocket.OPEN)
// #endif
// #ifndef H5
return !!this.ws
// #endif
}
/**
* 等待 WebSocket 进入可用OPEN状态
*
* 场景SSO / web-view 登录后 connect() 为异步建链CONNECTING→OPEN
* 设备预览结束立即入会时,首个 sendWithAck 可能早于 socket OPEN
* 导致 send() 返回 -1 → 业务侧抛 "连接未就绪"。
* 本方法在未连接时主动发起一次连接connect 对 OPEN/CONNECTING 幂等),
* 并等待 _connected 事件;已连接则立即 resolve超时 reject。
*
* @param {number} [timeoutMS=10000] - 最长等待时间
* @returns {Promise<boolean>}
*/
waitForConnected(timeoutMS = 10000) {
if (this.isConnected()) return Promise.resolve(true)
// 未建链 / 已断开:主动发起一次连接(幂等,不会重复建链)
this.connect()
return new Promise((resolve, reject) => {
let settled = false
let timer = null
let poll = null
const cleanup = () => {
this.off('_connected', onConnected)
if (timer) clearTimeout(timer)
if (poll) clearInterval(poll)
}
const onConnected = () => {
if (settled) return
settled = true
cleanup()
resolve(true)
}
timer = setTimeout(() => {
if (settled) return
settled = true
cleanup()
reject({ code: -1, message: '连接会议服务器超时' })
}, timeoutMS)
// 轮询兜底:防止 _onOpen 在 listener 注册前已触发而错过事件
poll = setInterval(() => {
if (this.isConnected()) onConnected()
}, 100)
this.on('_connected', onConnected)
})
}
/**
* 注册事件监听
* @param {string} event - 事件类型

View File

@@ -975,6 +975,16 @@ export const useMeetingStore = defineStore('meeting', () => {
}
localState.value = MEETING_LOCAL_STATE_CONNECTING
// 入会前确保 WS 已 OPENSSO/web-view 登录后 connect() 为异步建链,
// 设备预览后立即入会时,首个 sendWithAck 可能早于 socket OPEN
// 表现为 "加入失败:连接未就绪"。此处显式等待(必要时主动发起连接),最多 10s。
try {
await wsService.waitForConnected(10000)
} catch (e) {
_log('error', '[Meeting] 入会前等待 WS 连接超时', e)
throw new Error('连接会议服务器失败,请检查网络后重试')
}
const userStore = useUserStore()
const uid = userStore.userInfo?.id
if (!uid) throw new Error('当前用户未登录')