1595 lines
50 KiB
Vue
1595 lines
50 KiB
Vue
<template>
|
||
<view class="cloud-law-call">
|
||
<!-- 仅在"出错 / 已结束"时显示过渡页;正常 connecting 期间直接渲染会议外壳,
|
||
避免出现"正在进入视频咨询"这一中间画面 -->
|
||
<view v-if="joinError || ended" class="entry-state">
|
||
<view class="entry-avatar">{{ entryInitial }}</view>
|
||
<text class="entry-title">{{ entryTitle }}</text>
|
||
<text class="entry-desc">{{ joinError || stateText }}</text>
|
||
<button v-if="joinError" class="retry-btn" @click="joinMeeting">重新进入</button>
|
||
<button v-else-if="ended && buildMiniProgramReturnUrl()" class="retry-btn" @click="goToConsultDetail">查看咨询详情</button>
|
||
</view>
|
||
|
||
<view v-else class="call-shell" :class="{ 'voice-mode': isVoiceMode }">
|
||
<view v-if="!isVoiceMode" class="remote-stage">
|
||
<VideoTile
|
||
v-if="primaryTile"
|
||
:key="`main-${primaryTile.userId}-${primaryTile.videoTrack ? primaryTile.videoTrack.id : 'novideo'}`"
|
||
class="main-video clean-video-tile"
|
||
:user-id="primaryTile.userId"
|
||
:name="primaryTile.name"
|
||
:is-local="primaryTile.isLocal"
|
||
:is-host="primaryTile.isHost"
|
||
:audio-enabled="primaryTile.audioEnabled"
|
||
:video-enabled="primaryTile.videoEnabled"
|
||
:audio-track="primaryTile.audioTrack"
|
||
:video-track="primaryTile.videoTrack"
|
||
:is-speaking="!!speakingMap[primaryTile.userId]"
|
||
/>
|
||
<view v-else class="empty-video">
|
||
<view class="entry-avatar">{{ entryInitial }}</view>
|
||
<text class="entry-title">在线律师</text>
|
||
<text class="entry-desc">等待视频画面…</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="voice-stage">
|
||
<image class="voice-bg" :src="bgImage" mode="aspectFill"></image>
|
||
<view class="voice-profile">
|
||
<image v-if="voiceAvatar" class="voice-avatar" :src="voiceAvatar" mode="aspectFill"></image>
|
||
<view v-else class="voice-avatar voice-avatar-fallback">{{ voiceInitial }}</view>
|
||
<text class="voice-name">{{ voiceName }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="!isVoiceMode && secondaryTile" class="self-preview" @click="swapPrimaryTile">
|
||
<VideoTile
|
||
:key="`pip-${secondaryTile.userId}-${secondaryTile.videoTrack ? secondaryTile.videoTrack.id : 'novideo'}`"
|
||
class="self-video clean-video-tile"
|
||
:user-id="secondaryTile.userId"
|
||
:name="secondaryTile.name"
|
||
:is-local="secondaryTile.isLocal"
|
||
:is-host="secondaryTile.isHost"
|
||
:audio-enabled="secondaryTile.audioEnabled"
|
||
:video-enabled="secondaryTile.videoEnabled"
|
||
:audio-track="secondaryTile.audioTrack"
|
||
:video-track="secondaryTile.videoTrack"
|
||
:is-speaking="!!speakingMap[secondaryTile.userId]"
|
||
:compact="true"
|
||
/>
|
||
</view>
|
||
|
||
<view v-if="!isVoiceMode" class="video-topbar">
|
||
<text v-if="isLawRole" class="video-back" @click="minimizeToMiniProgram">‹</text>
|
||
<view class="top-window-icon" @click="swapPrimaryTile"></view>
|
||
<text class="top-duration">{{ durationText }}</text>
|
||
<text class="top-plus">+</text>
|
||
</view>
|
||
|
||
<view v-else class="voice-topbar">
|
||
<text class="voice-back" @click="onTopBack">‹</text>
|
||
</view>
|
||
|
||
<view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view>
|
||
|
||
<view class="controls" :class="isVoiceMode ? 'voice-controls' : 'video-controls'">
|
||
<view class="controls-row">
|
||
<view class="control-item" @click="onCamToggle" @tap="onCamToggle">
|
||
<view class="control-btn dark-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }">
|
||
<image class="control-image" :src="cameraIcon" mode="aspectFit"></image>
|
||
</view>
|
||
<text class="control-label">摄像头{{ videoEnabled ? '已开' : '已关' }}</text>
|
||
</view>
|
||
|
||
<view class="control-item" @click="toggleSpeaker">
|
||
<view class="control-btn dark-btn" :class="{ disabled: speakerMuted }">
|
||
<image class="control-image" :src="speakerIcon" mode="aspectFit"></image>
|
||
</view>
|
||
<text class="control-label">扬声器{{ speakerMuted ? '已关' : '已开' }}</text>
|
||
</view>
|
||
|
||
<view class="control-item" @click="onMicToggle" @tap="onMicToggle">
|
||
<view class="control-btn dark-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }">
|
||
<image class="control-image" :src="micIcon" mode="aspectFit"></image>
|
||
</view>
|
||
<text class="control-label">麦克风{{ audioEnabled ? '已开' : '已关' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="controls-row controls-row-bottom">
|
||
<!-- 律师端:底部左侧为共享屏幕(替代通用会议页「聊天」能力) -->
|
||
<view v-if="isLawRole" class="control-item" @click="onScreenToggle" @tap="onScreenToggle">
|
||
<view
|
||
class="control-btn dark-btn text-icon-btn"
|
||
:class="{ active: screenSharingByMe, loading: screenLoading, disabled: screenDisabled && !screenSharingByMe }"
|
||
>
|
||
<text class="text-icon">{{ screenSharingByMe ? '停' : '屏' }}</text>
|
||
</view>
|
||
<text class="control-label">{{ screenSharingByMe ? '停止共享' : '共享屏幕' }}</text>
|
||
</view>
|
||
<view v-else class="control-item" @click="takeSnapshot" @tap="takeSnapshot">
|
||
<view class="control-btn dark-btn text-icon-btn">
|
||
<image v-if="!captureIconError" class="control-image" :src="captureIcon" mode="aspectFit" @error="captureIconError = true"></image>
|
||
<text v-else class="text-icon">截</text>
|
||
</view>
|
||
<text class="control-label">截屏</text>
|
||
</view>
|
||
|
||
<view class="control-item" @click="hangup" @tap="hangup">
|
||
<view class="hangup-btn" :class="{ loading: leaving }">
|
||
<image class="hangup-image" :src="hangupIcon" mode="aspectFit"></image>
|
||
</view>
|
||
<text class="control-label">挂断</text>
|
||
</view>
|
||
|
||
<view class="control-item" @click="flipCamera">
|
||
<view class="control-btn dark-btn text-icon-btn">
|
||
<image v-if="!flipCameraIconError" class="control-image" :src="flipCameraIcon" mode="aspectFit" @error="flipCameraIconError = true"></image>
|
||
<text v-else class="text-icon">↻</text>
|
||
</view>
|
||
<text class="control-label">镜头翻转</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
</view>
|
||
|
||
<view v-if="snapshotPreviewUrl" class="snapshot-preview-mask" @click="closeSnapshotPreview">
|
||
<view class="snapshot-preview-panel" @click.stop>
|
||
<image class="snapshot-preview-image" :src="snapshotPreviewUrl" mode="aspectFit"></image>
|
||
<text class="snapshot-preview-tip">手机端请长按图片保存,电脑端可点击下载图片</text>
|
||
<view class="snapshot-preview-actions">
|
||
<button class="snapshot-preview-btn" @click="downloadSnapshotPreview">下载图片</button>
|
||
<button class="snapshot-preview-btn secondary" @click="closeSnapshotPreview">关闭</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||
import { useMeetingStore } from '@/store/meeting'
|
||
import { useUserStore } from '@/store/user'
|
||
import {
|
||
MEETING_LOCAL_STATE_CONNECTED,
|
||
MEETING_LOCAL_STATE_CONNECTING,
|
||
MEETING_LOCAL_STATE_JOINING,
|
||
MEETING_LOCAL_STATE_RECONNECTING,
|
||
MEETING_LOCAL_STATE_LEAVING,
|
||
MEETING_LOCAL_STATE_ENDED,
|
||
MEETING_ENDED_REASON_LABEL
|
||
} from '@/constants/meeting'
|
||
import VideoTile from '@/components/meeting/VideoTile.vue'
|
||
import bgImage from '@/static/bg.png'
|
||
import hangupIcon from '@/static/cacel.png'
|
||
import voiceOnIcon from '@/static/vocie_calling_1.png'
|
||
import voiceOffIcon from '@/static/vocie_stop.png'
|
||
import videoOnIcon from '@/static/vodie_calling_1.png'
|
||
import videoOffIcon from '@/static/vodie_stop.png'
|
||
import speakerOnIcon from '@/static/laba_open.png'
|
||
import speakerOffIcon from '@/static/laba_stop.png'
|
||
|
||
const meetingStore = useMeetingStore()
|
||
const userStore = useUserStore()
|
||
|
||
const rawCode = ref('')
|
||
const callId = ref('')
|
||
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('')
|
||
const audioLoading = ref(false)
|
||
const videoLoading = ref(false)
|
||
const screenLoading = ref(false)
|
||
const leaving = ref(false)
|
||
const startedAt = ref(0)
|
||
const nowTs = ref(Date.now())
|
||
const ended = ref(false)
|
||
const preferSelfMain = ref(false)
|
||
let timerHandle = null
|
||
let stateStopWatch = null
|
||
let videoRetryHandle = null
|
||
let commandPollHandle = null
|
||
let commandPolling = false
|
||
const speakerMuted = ref(false)
|
||
const cameraFacing = ref('user')
|
||
const captureIcon = '/static/capture.png'
|
||
const flipCameraIcon = '/static/camera.png'
|
||
const captureIconError = ref(false)
|
||
const flipCameraIconError = ref(false)
|
||
const snapshotPreviewUrl = ref('')
|
||
const snapshotBusy = ref(false)
|
||
/** 用户点击挂断(区别于返回上一页最小化会议) */
|
||
const userInitiatedHangup = ref(false)
|
||
/** 律师端从小程序 web-view 返回:仅本地离会,不结束云律通话、不通知小程序通话已结束 */
|
||
const minimizingToMiniProgram = ref(false)
|
||
|
||
const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9)
|
||
const sameUser = (a, b) => String(a || '') === String(b || '')
|
||
|
||
const formatCode = (value) => {
|
||
const digits = digitsOf(value)
|
||
if (digits.length !== 9) return String(value || '')
|
||
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
|
||
}
|
||
|
||
const formattedCode = computed(() => formatCode(rawCode.value))
|
||
const myUserId = computed(() => userStore.userInfo?.id || userStore.userInfo?.user_id || '')
|
||
const hostId = computed(() => meetingStore.currentRoom?.host_id || '')
|
||
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)
|
||
|
||
const entryInitial = computed(() => {
|
||
const name = userStore.userInfo?.nickname || userStore.userInfo?.username || '在'
|
||
return String(name || '在').slice(0, 1)
|
||
})
|
||
|
||
const entryTitle = computed(() => {
|
||
if (joinError.value) return '视频咨询接入失败'
|
||
if (ended.value) return '视频咨询已结束'
|
||
return '正在进入视频咨询'
|
||
})
|
||
|
||
const participants = computed(() => {
|
||
const list = meetingStore.activeParticipants || []
|
||
return list.map(p => {
|
||
if (!sameUser(p.user_id, myUserId.value)) return p
|
||
return {
|
||
...p,
|
||
audio_enabled: meetingStore.localAudioEnabled,
|
||
video_enabled: meetingStore.localVideoEnabled
|
||
}
|
||
})
|
||
})
|
||
|
||
const localTrackVersion = computed(() => {
|
||
const producers = meetingStore.localProducers || {}
|
||
return `${producers.audio || ''}-${producers.video || ''}-${producers.screen || ''}`
|
||
})
|
||
|
||
const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
|
||
const screenSharingByMe = computed(() => {
|
||
const owner = meetingStore.screenShare?.ownerUserId
|
||
return !!owner && sameUser(owner, myUserId.value)
|
||
})
|
||
const screenSupported = computed(() => {
|
||
// #ifdef H5
|
||
return typeof navigator !== 'undefined' &&
|
||
!!navigator.mediaDevices &&
|
||
typeof navigator.mediaDevices.getDisplayMedia === 'function'
|
||
// #endif
|
||
// #ifndef H5
|
||
return false
|
||
// #endif
|
||
})
|
||
const screenDisabled = computed(() => {
|
||
if (!screenSupported.value) return true
|
||
if (screenSharingActive.value && !screenSharingByMe.value) return true
|
||
return false
|
||
})
|
||
const screenDisabledReason = computed(() => {
|
||
if (!screenSupported.value) return '当前环境不支持屏幕共享'
|
||
if (screenSharingActive.value && !screenSharingByMe.value) return '已有成员正在共享屏幕'
|
||
return ''
|
||
})
|
||
|
||
const screenTile = computed(() => {
|
||
const owner = meetingStore.screenShare?.ownerUserId
|
||
if (!owner) return null
|
||
localTrackVersion.value
|
||
remoteTrackVersion.value
|
||
const isLocal = sameUser(owner, myUserId.value)
|
||
const track = isLocal
|
||
? meetingStore.getLocalTrack('screen')
|
||
: meetingStore.getRemoteTrack(owner, 'screen')
|
||
if (!track) return null
|
||
return {
|
||
key: `screen_${owner}`,
|
||
userId: owner,
|
||
name: isLocal ? '你' : nameOf(owner),
|
||
isLocal,
|
||
isHost: sameUser(owner, hostId.value),
|
||
audioEnabled: false,
|
||
videoEnabled: true,
|
||
audioTrack: null,
|
||
videoTrack: track
|
||
}
|
||
})
|
||
|
||
const remoteTrackVersion = computed(() => {
|
||
const parts = []
|
||
const map = meetingStore.remoteConsumers || {}
|
||
Object.keys(map).forEach(uid => {
|
||
const slot = map[uid]
|
||
parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}:${slot?.screen?.id || ''}`)
|
||
})
|
||
return parts.join('|')
|
||
})
|
||
|
||
const displayNameMap = computed(() => {
|
||
const map = {}
|
||
participants.value.forEach(p => {
|
||
if (p.user_name) map[p.user_id] = p.user_name
|
||
})
|
||
const me = userStore.userInfo || {}
|
||
const uid = me.id || me.user_id
|
||
if (uid) map[uid] = me.nickname || me.username || map[uid]
|
||
return map
|
||
})
|
||
|
||
const nameOf = (uid) => {
|
||
if (sameUser(uid, myUserId.value)) return '你'
|
||
return displayNameMap.value[uid] || `用户 ${uid}`
|
||
}
|
||
|
||
const tiles = computed(() => {
|
||
localTrackVersion.value
|
||
remoteTrackVersion.value
|
||
|
||
const list = []
|
||
participants.value.forEach(p => {
|
||
if (!p.is_active && p.is_active !== undefined) return
|
||
const isLocal = sameUser(p.user_id, myUserId.value)
|
||
const audioTrack = isLocal ? meetingStore.getLocalTrack('audio') : meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||
const videoTrack = isLocal ? meetingStore.getLocalTrack('video') : meetingStore.getRemoteTrack(p.user_id, 'video')
|
||
list.push({
|
||
key: `u_${p.user_id}`,
|
||
userId: p.user_id,
|
||
name: nameOf(p.user_id),
|
||
isLocal,
|
||
isHost: sameUser(p.user_id, hostId.value),
|
||
audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
|
||
videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
|
||
audioTrack,
|
||
videoTrack
|
||
})
|
||
})
|
||
|
||
if (myUserId.value && !list.some(t => t.isLocal)) {
|
||
list.unshift({
|
||
key: `u_${myUserId.value}`,
|
||
userId: myUserId.value,
|
||
name: '你',
|
||
isLocal: true,
|
||
isHost: sameUser(myUserId.value, hostId.value),
|
||
audioEnabled: audioEnabled.value,
|
||
videoEnabled: videoEnabled.value,
|
||
audioTrack: meetingStore.getLocalTrack('audio'),
|
||
videoTrack: meetingStore.getLocalTrack('video')
|
||
})
|
||
}
|
||
return list
|
||
})
|
||
|
||
const selfTile = computed(() => tiles.value.find(t => t.isLocal) || null)
|
||
const remoteTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || null)
|
||
const primaryTile = computed(() => {
|
||
if (screenTile.value) return screenTile.value
|
||
if (preferSelfMain.value && selfTile.value) return selfTile.value
|
||
return remoteTile.value || selfTile.value
|
||
})
|
||
const secondaryTile = computed(() => {
|
||
if (!primaryTile.value) return null
|
||
if (primaryTile.value.isLocal) return remoteTile.value
|
||
return selfTile.value
|
||
})
|
||
const voicePeer = computed(() => participants.value.find(p => !sameUser(p.user_id, myUserId.value)) || null)
|
||
const voiceName = computed(() => {
|
||
if (voicePeer.value?.user_name) return voicePeer.value.user_name
|
||
if (primaryTile.value && !primaryTile.value.isLocal) return primaryTile.value.name
|
||
return '在线律师'
|
||
})
|
||
const voiceAvatar = computed(() => voicePeer.value?.user_avatar || '')
|
||
const voiceInitial = computed(() => String(voiceName.value || '在').slice(0, 1))
|
||
|
||
const isReady = computed(() => meetingStore.localState === MEETING_LOCAL_STATE_CONNECTED && !joinError.value && !ended.value)
|
||
|
||
const stateClass = computed(() => {
|
||
switch (meetingStore.localState) {
|
||
case MEETING_LOCAL_STATE_CONNECTED: return 'connected'
|
||
case MEETING_LOCAL_STATE_RECONNECTING: return 'reconnecting'
|
||
case MEETING_LOCAL_STATE_CONNECTING:
|
||
case MEETING_LOCAL_STATE_JOINING: return 'connecting'
|
||
case MEETING_LOCAL_STATE_LEAVING: return 'leaving'
|
||
case MEETING_LOCAL_STATE_ENDED: return 'ended'
|
||
default: return 'idle'
|
||
}
|
||
})
|
||
|
||
const stateText = computed(() => {
|
||
if (joinError.value) return joinError.value
|
||
if (joining.value) return '正在接入视频画面…'
|
||
switch (meetingStore.localState) {
|
||
case MEETING_LOCAL_STATE_CONNECTED: return '已接通'
|
||
case MEETING_LOCAL_STATE_RECONNECTING: return '重连中'
|
||
case MEETING_LOCAL_STATE_CONNECTING:
|
||
case MEETING_LOCAL_STATE_JOINING: return '连接中'
|
||
case MEETING_LOCAL_STATE_LEAVING: return '正在挂断'
|
||
case MEETING_LOCAL_STATE_ENDED: return '已结束'
|
||
default: return '等待接通'
|
||
}
|
||
})
|
||
|
||
const durationText = computed(() => {
|
||
if (!startedAt.value) return '00:00'
|
||
const diff = Math.max(0, Math.floor((nowTs.value - startedAt.value) / 1000))
|
||
const h = Math.floor(diff / 3600)
|
||
const m = Math.floor((diff % 3600) / 60)
|
||
const s = diff % 60
|
||
const pad = n => (n < 10 ? `0${n}` : String(n))
|
||
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
|
||
})
|
||
|
||
const startTimer = () => {
|
||
if (!startedAt.value) {
|
||
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
|
||
startedAt.value = startIso ? Date.parse(startIso) : Date.now()
|
||
}
|
||
if (!timerHandle) {
|
||
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
|
||
}
|
||
}
|
||
|
||
const localMediaError = ref('')
|
||
|
||
const resolveCloudLawApiUrl = (path) => {
|
||
const base = String(cloudLawApiBase.value || '').replace(/\/+$/, '')
|
||
return `${base}${path}`
|
||
}
|
||
|
||
const requestCloudLawCommands = (url) => {
|
||
return new Promise((resolve, reject) => {
|
||
uni.request({
|
||
url,
|
||
method: 'GET',
|
||
timeout: 8000,
|
||
success: (res) => {
|
||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||
resolve(res.data || {})
|
||
} else {
|
||
reject(res.data || { message: `HTTP ${res.statusCode}` })
|
||
}
|
||
},
|
||
fail: reject
|
||
})
|
||
})
|
||
}
|
||
|
||
const notifyCloudLawEnd = () => {
|
||
if (!callId.value || !cloudLawApiBase.value) return Promise.resolve()
|
||
return new Promise(resolve => {
|
||
uni.request({
|
||
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' },
|
||
timeout: 5000,
|
||
complete: resolve
|
||
})
|
||
})
|
||
}
|
||
|
||
const buildMiniProgramReturnUrl = () => {
|
||
const decodeRepeated = (value) => {
|
||
let text = String(value || '').trim()
|
||
if (!text) return ''
|
||
for (let i = 0; i < 3; i++) {
|
||
try {
|
||
const next = decodeURIComponent(text)
|
||
if (next === text) break
|
||
text = next
|
||
} catch (e) {
|
||
break
|
||
}
|
||
}
|
||
return text
|
||
}
|
||
const configured = decodeRepeated(miniProgramReturnUrl.value)
|
||
if (configured) {
|
||
return configured
|
||
}
|
||
if (!consultId.value) {
|
||
// #ifdef H5
|
||
try {
|
||
const cached = window.sessionStorage.getItem('echo_cloud_law_return_url') || ''
|
||
if (cached) return decodeRepeated(cached)
|
||
} catch (e) {}
|
||
// #endif
|
||
return ''
|
||
}
|
||
return `/pages_work/ai_consult/ai_consult?id=${encodeURIComponent(consultId.value)}&mode=detail&from=videoCall`
|
||
}
|
||
|
||
let endedRedirectTimer = null
|
||
let miniProgramRedirected = false
|
||
let miniProgramEndNotified = false
|
||
let miniProgramRedirectRetryTimer = null
|
||
|
||
const getMiniProgram = () => {
|
||
return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
|
||
}
|
||
|
||
const navigateMiniProgramToReturnUrl = (returnUrl) => {
|
||
const mp = getMiniProgram()
|
||
if (!mp) return false
|
||
const url = String(returnUrl || '').trim()
|
||
if (url) {
|
||
const attempts = [
|
||
() => mp.reLaunch && mp.reLaunch({ url }),
|
||
() => mp.redirectTo && mp.redirectTo({ url }),
|
||
() => mp.navigateTo && mp.navigateTo({ url })
|
||
]
|
||
for (let i = 0; i < attempts.length; i++) {
|
||
try {
|
||
attempts[i]()
|
||
return true
|
||
} catch (e) {
|
||
console.warn('[CloudLawVideoCall] miniProgram navigate failed', e)
|
||
}
|
||
}
|
||
}
|
||
try {
|
||
if (typeof mp.navigateBack === 'function') {
|
||
mp.navigateBack({ delta: 1 })
|
||
return true
|
||
}
|
||
} catch (e) {
|
||
console.warn('[CloudLawVideoCall] miniProgram navigateBack failed', e)
|
||
}
|
||
return false
|
||
}
|
||
|
||
const notifyMiniProgramCallEnded = () => {
|
||
if (isLawRole.value) return
|
||
const returnUrl = buildMiniProgramReturnUrl()
|
||
const payload = {
|
||
type: 'cloudLawCallEnded',
|
||
action: 'cloudLawCallEnded',
|
||
callId: callId.value,
|
||
consultId: consultId.value,
|
||
returnUrl
|
||
}
|
||
const miniProgram = getMiniProgram()
|
||
try {
|
||
// postMessage 只在 web-view 销毁/分享等时机投递;先发一份,作为小程序侧 onMessage 的兜底
|
||
if (miniProgram && typeof miniProgram.postMessage === 'function') {
|
||
miniProgram.postMessage({ data: payload })
|
||
}
|
||
} catch (e) {
|
||
console.warn('[CloudLawVideoCall] postMessage failed', e)
|
||
}
|
||
if (miniProgramRedirected) return
|
||
if (navigateMiniProgramToReturnUrl(returnUrl)) {
|
||
miniProgramRedirected = true
|
||
return
|
||
}
|
||
if (miniProgramRedirectRetryTimer) return
|
||
let retry = 0
|
||
miniProgramRedirectRetryTimer = setInterval(() => {
|
||
retry += 1
|
||
if (navigateMiniProgramToReturnUrl(returnUrl) || retry >= 10) {
|
||
clearInterval(miniProgramRedirectRetryTimer)
|
||
miniProgramRedirectRetryTimer = null
|
||
if (retry < 10) miniProgramRedirected = true
|
||
}
|
||
}, 300)
|
||
}
|
||
|
||
const notifyMiniProgramCallEndedOnce = () => {
|
||
if (miniProgramEndNotified) return
|
||
miniProgramEndNotified = true
|
||
notifyMiniProgramCallEnded()
|
||
}
|
||
|
||
const scheduleEndedRedirect = () => {
|
||
if (endedRedirectTimer || isLawRole.value) return
|
||
endedRedirectTimer = setTimeout(() => {
|
||
endedRedirectTimer = null
|
||
notifyCloudLawEnd().finally(() => notifyMiniProgramCallEndedOnce())
|
||
}, 1200)
|
||
}
|
||
|
||
const goToConsultDetail = () => {
|
||
notifyCloudLawEnd().finally(() => notifyMiniProgramCallEndedOnce())
|
||
}
|
||
|
||
const pollCommandsOnce = async () => {
|
||
if (!callId.value || !meetingStore.isInMeeting || leaving.value || ended.value || commandPolling) return
|
||
commandPolling = true
|
||
try {
|
||
const room = meetingStore.currentRoom?.room_code || formattedCode.value || ''
|
||
const path = `/api/applet/voiceConnection/publicCommands/${encodeURIComponent(callId.value)}${room ? `?roomCode=${encodeURIComponent(room)}` : ''}`
|
||
const resp = await requestCloudLawCommands(resolveCloudLawApiUrl(path))
|
||
const payload = resp?.data || resp || {}
|
||
const commands = payload.commands || payload.data?.commands || []
|
||
for (const command of commands) {
|
||
await executeCloudLawCommand(command)
|
||
}
|
||
} catch (err) {
|
||
console.warn('[CloudLawVideoCall] poll commands failed', err)
|
||
} finally {
|
||
commandPolling = false
|
||
}
|
||
}
|
||
|
||
const startCommandPolling = () => {
|
||
// 律师端(接单端)无 publicCommands 远程指令通道,跳过轮询避免无谓 404
|
||
if (isLawRole.value) return
|
||
if (!callId.value || commandPollHandle) return
|
||
pollCommandsOnce()
|
||
commandPollHandle = setInterval(pollCommandsOnce, 1200)
|
||
}
|
||
|
||
const stopCommandPolling = () => {
|
||
if (commandPollHandle) {
|
||
clearInterval(commandPollHandle)
|
||
commandPollHandle = null
|
||
}
|
||
}
|
||
|
||
const setSpeakerMuted = (muted) => {
|
||
speakerMuted.value = muted
|
||
// #ifdef H5
|
||
try {
|
||
const audioEls = document.querySelectorAll('.cloud-law-call audio')
|
||
audioEls.forEach(el => {
|
||
el.muted = muted
|
||
el.volume = muted ? 0 : 1
|
||
})
|
||
} catch (err) {
|
||
console.warn('[CloudLawVideoCall] set speaker failed', err)
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
const toggleSpeaker = () => {
|
||
setSpeakerMuted(!speakerMuted.value)
|
||
}
|
||
|
||
const isMobileSnapshotBrowser = () => {
|
||
// #ifdef H5
|
||
const ua = navigator?.userAgent || ''
|
||
return /Android|iPhone|iPad|iPod|Mobile|MicroMessenger/i.test(ua)
|
||
// #endif
|
||
// #ifndef H5
|
||
return false
|
||
// #endif
|
||
}
|
||
|
||
const findSnapshotVideo = () => {
|
||
// #ifdef H5
|
||
const selectors = [
|
||
'.cloud-law-call .main-video video',
|
||
'.cloud-law-call .remote-stage video',
|
||
'.cloud-law-call video'
|
||
]
|
||
for (const selector of selectors) {
|
||
const list = Array.from(document.querySelectorAll(selector))
|
||
const video = list.find(el => el && el.videoWidth && el.videoHeight && el.readyState >= 2)
|
||
if (video) return video
|
||
}
|
||
// #endif
|
||
return null
|
||
}
|
||
|
||
const downloadSnapshotDataUrl = (dataUrl) => {
|
||
// #ifdef H5
|
||
const a = document.createElement('a')
|
||
a.href = dataUrl
|
||
a.download = `cloud-law-video-${Date.now()}.png`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
// #endif
|
||
}
|
||
|
||
const closeSnapshotPreview = () => {
|
||
snapshotPreviewUrl.value = ''
|
||
}
|
||
|
||
const downloadSnapshotPreview = () => {
|
||
if (!snapshotPreviewUrl.value) return
|
||
downloadSnapshotDataUrl(snapshotPreviewUrl.value)
|
||
uni.showToast({ title: isMobileSnapshotBrowser() ? '如未下载,请长按图片保存' : '截屏已下载', icon: 'none' })
|
||
}
|
||
|
||
const takeSnapshot = () => {
|
||
if (snapshotBusy.value) return
|
||
snapshotBusy.value = true
|
||
setTimeout(() => {
|
||
snapshotBusy.value = false
|
||
}, 600)
|
||
// #ifdef H5
|
||
try {
|
||
const video = findSnapshotVideo()
|
||
if (!video || !video.videoWidth || !video.videoHeight) {
|
||
uni.showToast({ title: '当前无视频画面可截屏', icon: 'none' })
|
||
return
|
||
}
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = video.videoWidth
|
||
canvas.height = video.videoHeight
|
||
const ctx = canvas.getContext('2d')
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||
const dataUrl = canvas.toDataURL('image/png')
|
||
snapshotPreviewUrl.value = dataUrl
|
||
if (isMobileSnapshotBrowser()) {
|
||
uni.showToast({ title: '截屏成功,长按图片保存', icon: 'none' })
|
||
} else {
|
||
downloadSnapshotDataUrl(dataUrl)
|
||
uni.showToast({ title: '截屏已下载', icon: 'success' })
|
||
}
|
||
} catch (err) {
|
||
console.warn('[CloudLawVideoCall] snapshot failed', err)
|
||
uni.showToast({ title: '截屏失败', icon: 'none' })
|
||
}
|
||
// #endif
|
||
// #ifndef H5
|
||
uni.showToast({ title: '当前环境暂不支持截屏', icon: 'none' })
|
||
// #endif
|
||
}
|
||
|
||
const flipCameraByCommand = async (value) => {
|
||
const nextFacing = value === 'front' || value === 'user'
|
||
? 'user'
|
||
: value === 'back' || value === 'environment'
|
||
? 'environment'
|
||
: (cameraFacing.value === 'user' ? 'environment' : 'user')
|
||
cameraFacing.value = nextFacing
|
||
try {
|
||
if (meetingStore.localProducers?.video) {
|
||
await meetingStore.switchLocalCameraFacing(nextFacing)
|
||
} else {
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: false,
|
||
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: nextFacing }
|
||
})
|
||
const track = stream.getVideoTracks()[0]
|
||
await meetingStore.startLocalVideo(undefined, track)
|
||
}
|
||
localMediaError.value = ''
|
||
} catch (err) {
|
||
console.warn('[CloudLawVideoCall] flip camera failed', err)
|
||
localMediaError.value = `镜头翻转:${err?.message || err}`
|
||
}
|
||
}
|
||
|
||
const flipCamera = () => {
|
||
flipCameraByCommand()
|
||
}
|
||
|
||
const onScreenToggle = async () => {
|
||
if (screenLoading.value) return
|
||
if (screenDisabled.value && !screenSharingByMe.value) {
|
||
uni.showToast({ title: screenDisabledReason.value || '无法共享屏幕', icon: 'none' })
|
||
return
|
||
}
|
||
screenLoading.value = true
|
||
try {
|
||
if (screenSharingByMe.value) {
|
||
await meetingStore.stopScreenShare()
|
||
} else {
|
||
await meetingStore.startScreenShare()
|
||
}
|
||
} catch (err) {
|
||
if (err?.name !== 'NotAllowedError') {
|
||
uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
|
||
}
|
||
} finally {
|
||
screenLoading.value = false
|
||
}
|
||
}
|
||
|
||
const executeCloudLawCommand = async (cmd = {}) => {
|
||
const command = String(cmd.command || cmd.type || '').toLowerCase()
|
||
const value = cmd.value
|
||
if (command === 'speaker') {
|
||
setSpeakerMuted(value === 'off' || value === false || value === 'false' || value === '0')
|
||
return
|
||
}
|
||
if (command === 'screenshot') {
|
||
takeSnapshot()
|
||
return
|
||
}
|
||
if (command === 'flipcamera' || command === 'flip_camera') {
|
||
await flipCameraByCommand(value)
|
||
}
|
||
}
|
||
|
||
|
||
const joinMeeting = async () => {
|
||
if (joining.value) return
|
||
const code = formattedCode.value
|
||
if (digitsOf(code).length !== 9) {
|
||
joinError.value = '会议号为空'
|
||
return
|
||
}
|
||
joining.value = true
|
||
joinError.value = ''
|
||
ended.value = false
|
||
try {
|
||
const currentCode = formatCode(meetingStore.currentRoom?.room_code || '')
|
||
if (meetingStore.isInMeeting && currentCode && digitsOf(currentCode) === digitsOf(code)) {
|
||
startTimer()
|
||
return
|
||
}
|
||
if (meetingStore.isInMeeting) {
|
||
await meetingStore.leave().catch(() => {})
|
||
}
|
||
const startWithVideo = !isVoiceMode.value
|
||
meetingStore.devicePreview = {
|
||
audioDeviceId: '',
|
||
videoDeviceId: '',
|
||
speakerDeviceId: '',
|
||
displayName: userStore.userInfo?.nickname || userStore.userInfo?.username || '',
|
||
startAudio: true,
|
||
startVideo: startWithVideo
|
||
}
|
||
|
||
// 关键:和 preview.vue 一致,进会前先 getUserMedia 拿到 audio+video 的原始 track,
|
||
// 再把 track 直接交给 joinAndEnter 复用,避免 mediasoup 入会后再次 getUserMedia
|
||
// 在 iOS Safari/微信 WebView 上触发 NotReadableError / 权限拒绝。
|
||
const prefs = await prepareLocalMediaTracks(startWithVideo)
|
||
|
||
await meetingStore.joinAndEnter(code, '', prefs)
|
||
startTimer()
|
||
scheduleVideoRetry()
|
||
startCommandPolling()
|
||
} catch (err) {
|
||
joinError.value = err?.message || '视频咨询接入失败'
|
||
console.error('[CloudLawVideoCall] join failed', err)
|
||
} finally {
|
||
joining.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 进会前同步申请本地音视频。与 preview.vue 同策略:
|
||
* 一次 getUserMedia 拿到 audio + video track 直接交给 mediasoup produce,
|
||
* 避免入会后再调 getUserMedia 在某些 WebView/Safari 里失败导致黑屏。
|
||
*/
|
||
const prepareLocalMediaTracks = async (startWithVideo) => {
|
||
const prefs = {
|
||
startAudio: true,
|
||
startVideo: startWithVideo,
|
||
audioDeviceId: '',
|
||
videoDeviceId: '',
|
||
audioTrack: null,
|
||
videoTrack: null
|
||
}
|
||
// #ifdef H5
|
||
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
console.warn('[CloudLawVideoCall] navigator.mediaDevices 不可用,跳过预申请')
|
||
return prefs
|
||
}
|
||
try {
|
||
const constraints = {
|
||
audio: true,
|
||
video: startWithVideo
|
||
? { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15, max: 24 }, facingMode: 'user' }
|
||
: false
|
||
}
|
||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||
prefs.audioTrack = stream.getAudioTracks()[0] || null
|
||
prefs.videoTrack = stream.getVideoTracks()[0] || null
|
||
// 如果用户拒了视频,stream 里不会有 video track,自动降级为不开摄像头
|
||
if (startWithVideo && !prefs.videoTrack) {
|
||
prefs.startVideo = false
|
||
}
|
||
if (!prefs.audioTrack) {
|
||
prefs.startAudio = false
|
||
}
|
||
} catch (err) {
|
||
console.error('[CloudLawVideoCall] prepare media failed', err)
|
||
localMediaError.value = `预申请音视频失败:${err?.name || ''} ${err?.message || err}`
|
||
// 拿不到也继续走 joinAndEnter,让后端协议建好;用户可以在入会后手动点麦/摄像头按钮重试
|
||
prefs.startAudio = false
|
||
prefs.startVideo = false
|
||
}
|
||
// #endif
|
||
return prefs
|
||
}
|
||
|
||
const scheduleVideoRetry = () => {
|
||
if (isVoiceMode.value || videoRetryHandle || videoEnabled.value) return
|
||
videoRetryHandle = setTimeout(async () => {
|
||
videoRetryHandle = null
|
||
if (isVoiceMode.value || videoEnabled.value || leaving.value || !meetingStore.isInMeeting) return
|
||
try {
|
||
await meetingStore.startLocalVideo()
|
||
} catch (err) {
|
||
console.error('[CloudLawVideoCall] retry start video failed', err)
|
||
localMediaError.value = `自动开摄像头失败:${err?.message || err}`
|
||
}
|
||
if (!meetingStore.localAudioEnabled) {
|
||
try {
|
||
await meetingStore.startLocalAudio()
|
||
} catch (err) {
|
||
console.error('[CloudLawVideoCall] retry start audio failed', err)
|
||
localMediaError.value = `${localMediaError.value || ''} | 自动开麦克风失败:${err?.message || err}`
|
||
}
|
||
}
|
||
}, 800)
|
||
}
|
||
|
||
const swapPrimaryTile = () => {
|
||
if (!selfTile.value || !remoteTile.value) return
|
||
preferSelfMain.value = !preferSelfMain.value
|
||
}
|
||
|
||
// 关键:在用户点击的"手势作用域"内自己 getUserMedia,把 track 传给 meetingStore,
|
||
// 避免 mediasoup 内部二次 getUserMedia 被 WebView 拒(NotAllowedError Permission denied)。
|
||
const acquireAudioTrack = async () => {
|
||
if (!navigator?.mediaDevices?.getUserMedia) throw new Error('当前环境不支持 getUserMedia')
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||
return stream.getAudioTracks()[0]
|
||
}
|
||
const acquireVideoTrack = async (facingMode = 'user') => {
|
||
if (!navigator?.mediaDevices?.getUserMedia) throw new Error('当前环境不支持 getUserMedia')
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: false,
|
||
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode }
|
||
})
|
||
return stream.getVideoTracks()[0]
|
||
}
|
||
|
||
const onMicToggle = async () => {
|
||
if (audioLoading.value || leaving.value) return
|
||
audioLoading.value = true
|
||
try {
|
||
if (meetingStore.localProducers?.audio) {
|
||
// 已经有 producer:只 mute/unmute,不释放硬件(WebView 释放后再申请会被拒)
|
||
await meetingStore.setLocalAudioMuted(audioEnabled.value)
|
||
} else {
|
||
// 没 producer:兜底走完整流程,在用户手势作用域内 getUserMedia
|
||
const track = await acquireAudioTrack()
|
||
await meetingStore.startLocalAudio(undefined, track)
|
||
}
|
||
localMediaError.value = ''
|
||
} catch (err) {
|
||
console.error('[CloudLawVideoCall] mic toggle failed', err)
|
||
localMediaError.value = `麦克风:${err?.message || err}`
|
||
} finally {
|
||
audioLoading.value = false
|
||
}
|
||
}
|
||
|
||
const onCamToggle = async () => {
|
||
if (videoLoading.value || leaving.value) return
|
||
videoLoading.value = true
|
||
try {
|
||
if (meetingStore.localProducers?.video) {
|
||
await meetingStore.setLocalVideoMuted(videoEnabled.value)
|
||
} else {
|
||
const track = await acquireVideoTrack(cameraFacing.value)
|
||
await meetingStore.startLocalVideo(undefined, track)
|
||
}
|
||
localMediaError.value = ''
|
||
} catch (err) {
|
||
console.error('[CloudLawVideoCall] cam toggle failed', err)
|
||
localMediaError.value = `摄像头:${err?.message || err}`
|
||
} finally {
|
||
videoLoading.value = false
|
||
}
|
||
}
|
||
|
||
const onTopBack = () => {
|
||
if (isLawRole.value) {
|
||
minimizeToMiniProgram()
|
||
} else {
|
||
hangup()
|
||
}
|
||
}
|
||
|
||
/** 律师端返回小程序上一页:保留进行中通话,便于「返回会议」浮窗再次进入 */
|
||
const minimizeToMiniProgram = () => {
|
||
if (leaving.value || ended.value || minimizingToMiniProgram.value) return
|
||
minimizingToMiniProgram.value = true
|
||
stopCommandPolling()
|
||
meetingStore.leave().catch(() => {})
|
||
const mp = getMiniProgram()
|
||
try {
|
||
if (mp && typeof mp.postMessage === 'function') {
|
||
mp.postMessage({
|
||
data: {
|
||
type: 'cloudLawMinimized',
|
||
action: 'cloudLawMinimized',
|
||
callId: callId.value
|
||
}
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.warn('[CloudLawVideoCall] minimize postMessage failed', e)
|
||
}
|
||
try {
|
||
if (mp && typeof mp.navigateBack === 'function') {
|
||
mp.navigateBack({ delta: 1 })
|
||
return
|
||
}
|
||
} catch (e) {
|
||
console.warn('[CloudLawVideoCall] minimize navigateBack failed', e)
|
||
}
|
||
minimizingToMiniProgram.value = false
|
||
uni.showToast({ title: '请使用左上角返回', icon: 'none' })
|
||
}
|
||
|
||
const hangup = async () => {
|
||
if (leaving.value) return
|
||
userInitiatedHangup.value = true
|
||
leaving.value = true
|
||
stopCommandPolling()
|
||
try {
|
||
// 先通知云律后端结束本次通话:
|
||
// - 用户端:/api/applet/voiceConnection/{callId}/end
|
||
// - 律师端:/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议(双方都收到 room.ended)
|
||
await notifyCloudLawEnd()
|
||
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.isHost(host_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] end/hangup failed, fallback leave', err)
|
||
// 失败时兜底 leave,至少自己先退出,避免卡死
|
||
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
|
||
} finally {
|
||
ended.value = true
|
||
leaving.value = false
|
||
notifyMiniProgramCallEndedOnce()
|
||
}
|
||
}
|
||
|
||
onLoad(query => {
|
||
const decodeQueryValue = (value) => {
|
||
let text = String(value || '').trim()
|
||
if (!text) return ''
|
||
for (let i = 0; i < 3; i++) {
|
||
try {
|
||
const next = decodeURIComponent(text)
|
||
if (next === text) break
|
||
text = next
|
||
} catch (e) {
|
||
break
|
||
}
|
||
}
|
||
return text
|
||
}
|
||
rawCode.value = query?.code || query?.roomCode || ''
|
||
callId.value = query?.callId || query?.call_id || ''
|
||
consultId.value = decodeQueryValue(query?.consultId || query?.consult_id || '')
|
||
miniProgramReturnUrl.value = decodeQueryValue(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 || ''
|
||
// #ifdef H5
|
||
try {
|
||
if (consultId.value) {
|
||
window.sessionStorage.setItem('echo_cloud_law_consult_id', consultId.value)
|
||
}
|
||
if (miniProgramReturnUrl.value) {
|
||
window.sessionStorage.setItem('echo_cloud_law_return_url', miniProgramReturnUrl.value)
|
||
}
|
||
} catch (e) {}
|
||
if (String(cloudLawRole.value).toLowerCase() === 'law') {
|
||
try {
|
||
window.sessionStorage.setItem('echo_cloud_law_meeting_role', 'law')
|
||
} catch (e) {}
|
||
}
|
||
// #endif
|
||
const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase()
|
||
callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
|
||
joinMeeting()
|
||
})
|
||
|
||
onMounted(() => {
|
||
stateStopWatch = watch(() => meetingStore.localState, s => {
|
||
if (s === MEETING_LOCAL_STATE_CONNECTED) {
|
||
startTimer()
|
||
scheduleVideoRetry()
|
||
startCommandPolling()
|
||
}
|
||
if (s === MEETING_LOCAL_STATE_ENDED) {
|
||
stopCommandPolling()
|
||
// 律师端仅「返回上一页」最小化:不结束云律通话、不 postMessage 通知小程序通话结束
|
||
if (minimizingToMiniProgram.value) {
|
||
minimizingToMiniProgram.value = false
|
||
return
|
||
}
|
||
const reason = meetingStore.lastEndedReason
|
||
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束'
|
||
uni.showToast({ title: label, icon: 'none' })
|
||
ended.value = true
|
||
// 对方挂断或会议结束时,通知小程序跳转到「我的咨询详情」
|
||
notifyCloudLawEnd().finally(() => {
|
||
notifyMiniProgramCallEndedOnce()
|
||
scheduleEndedRedirect()
|
||
})
|
||
}
|
||
}, { immediate: true })
|
||
})
|
||
|
||
watch(ended, (value) => {
|
||
if (!value || isLawRole.value) return
|
||
const returnUrl = buildMiniProgramReturnUrl()
|
||
if (!returnUrl) return
|
||
setTimeout(() => {
|
||
if (ended.value && !miniProgramRedirected) {
|
||
notifyMiniProgramCallEndedOnce()
|
||
}
|
||
}, 1500)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (endedRedirectTimer) {
|
||
clearTimeout(endedRedirectTimer)
|
||
endedRedirectTimer = null
|
||
}
|
||
if (miniProgramRedirectRetryTimer) {
|
||
clearInterval(miniProgramRedirectRetryTimer)
|
||
miniProgramRedirectRetryTimer = null
|
||
}
|
||
if (timerHandle) {
|
||
clearInterval(timerHandle)
|
||
timerHandle = null
|
||
}
|
||
if (stateStopWatch) {
|
||
stateStopWatch()
|
||
stateStopWatch = null
|
||
}
|
||
if (videoRetryHandle) {
|
||
clearTimeout(videoRetryHandle)
|
||
videoRetryHandle = null
|
||
}
|
||
stopCommandPolling()
|
||
})
|
||
|
||
onUnload(() => {
|
||
stopCommandPolling()
|
||
if (isLawRole.value && !ended.value && !userInitiatedHangup.value) {
|
||
minimizingToMiniProgram.value = true
|
||
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
|
||
meetingStore.leave().catch(() => {})
|
||
}
|
||
return
|
||
}
|
||
if (!ended.value && callId.value) {
|
||
notifyCloudLawEnd().catch(() => {})
|
||
}
|
||
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
|
||
meetingStore.leave().catch(() => {})
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cloud-law-call {
|
||
position: fixed;
|
||
inset: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
/* [视频咨询 2026-05-24] 背景统一为静态 bg.png(前后两屏视觉一致) */
|
||
background: #050b1d url('/static/bg.png') center center / cover no-repeat;
|
||
color: #ffffff;
|
||
z-index: 1000;
|
||
}
|
||
.entry-state {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 48rpx;
|
||
box-sizing: border-box;
|
||
text-align: center;
|
||
}
|
||
.entry-avatar {
|
||
width: 136rpx;
|
||
height: 136rpx;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #43b1ff 0%, #1974e8 100%);
|
||
border: 4rpx solid rgba(255, 255, 255, 0.75);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 56rpx;
|
||
font-weight: 700;
|
||
box-shadow: 0 18rpx 60rpx rgba(25, 116, 232, 0.36);
|
||
}
|
||
.entry-title {
|
||
margin-top: 28rpx;
|
||
font-size: 34rpx;
|
||
font-weight: 700;
|
||
}
|
||
.entry-desc {
|
||
margin-top: 14rpx;
|
||
max-width: 560rpx;
|
||
font-size: 24rpx;
|
||
line-height: 1.5;
|
||
color: rgba(255, 255, 255, 0.72);
|
||
}
|
||
.retry-btn {
|
||
margin-top: 36rpx;
|
||
width: 260rpx;
|
||
height: 76rpx;
|
||
line-height: 76rpx;
|
||
border-radius: 999rpx;
|
||
background: #1664ff;
|
||
color: #ffffff;
|
||
font-size: 28rpx;
|
||
}
|
||
.call-shell {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.remote-stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: #050b1d;
|
||
}
|
||
.voice-stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
.voice-bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.voice-profile {
|
||
position: relative;
|
||
z-index: 2;
|
||
margin-top: calc(126rpx + env(safe-area-inset-top));
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
.voice-avatar {
|
||
width: 132rpx;
|
||
height: 132rpx;
|
||
border-radius: 50%;
|
||
border: 4rpx solid rgba(255, 255, 255, 0.86);
|
||
box-shadow: 0 12rpx 36rpx rgba(0, 0, 0, 0.28);
|
||
}
|
||
.voice-avatar-fallback {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: linear-gradient(135deg, #43b1ff 0%, #1974e8 100%);
|
||
color: #ffffff;
|
||
font-size: 54rpx;
|
||
font-weight: 700;
|
||
}
|
||
.voice-name {
|
||
margin-top: 24rpx;
|
||
font-size: 28rpx;
|
||
line-height: 1;
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
.main-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 0;
|
||
}
|
||
.empty-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.self-preview {
|
||
position: absolute;
|
||
right: 30rpx;
|
||
top: calc(104rpx + env(safe-area-inset-top));
|
||
width: 220rpx;
|
||
height: 310rpx;
|
||
border-radius: 10rpx;
|
||
overflow: hidden;
|
||
border: 0;
|
||
box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.38);
|
||
background: #0b1220;
|
||
z-index: 5;
|
||
}
|
||
.self-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 0;
|
||
}
|
||
.video-back {
|
||
font-size: 56rpx;
|
||
line-height: 1;
|
||
color: #ffffff;
|
||
padding: 0 12rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
.video-topbar {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
height: calc(88rpx + env(safe-area-inset-top));
|
||
padding: env(safe-area-inset-top) 46rpx 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
|
||
box-sizing: border-box;
|
||
z-index: 6;
|
||
}
|
||
.top-window-icon {
|
||
position: relative;
|
||
width: 42rpx;
|
||
height: 42rpx;
|
||
border: 4rpx solid rgba(255, 255, 255, 0.9);
|
||
border-radius: 6rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
.top-window-icon::after {
|
||
content: '';
|
||
position: absolute;
|
||
right: -16rpx;
|
||
bottom: -14rpx;
|
||
width: 24rpx;
|
||
height: 24rpx;
|
||
border: 4rpx solid rgba(255, 255, 255, 0.9);
|
||
border-radius: 5rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
.top-duration {
|
||
font-size: 36rpx;
|
||
color: #ffffff;
|
||
font-weight: 500;
|
||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
|
||
}
|
||
.top-plus {
|
||
color: #ffffff;
|
||
font-size: 56rpx;
|
||
line-height: 1;
|
||
font-weight: 300;
|
||
}
|
||
.voice-topbar {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
height: calc(88rpx + env(safe-area-inset-top));
|
||
padding: env(safe-area-inset-top) 26rpx 0;
|
||
display: flex;
|
||
align-items: center;
|
||
box-sizing: border-box;
|
||
z-index: 6;
|
||
}
|
||
.voice-back {
|
||
color: #ffffff;
|
||
font-size: 54rpx;
|
||
line-height: 1;
|
||
font-weight: 300;
|
||
}
|
||
.voice-duration {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: calc(310rpx + env(safe-area-inset-bottom));
|
||
transform: translateX(-50%);
|
||
padding: 10rpx 20rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
color: rgba(255, 255, 255, 0.78);
|
||
font-size: 24rpx;
|
||
line-height: 1;
|
||
z-index: 7;
|
||
}
|
||
.controls {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: calc(112rpx + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
z-index: 8;
|
||
}
|
||
.video-controls {
|
||
padding: 0 58rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 30rpx;
|
||
background: transparent;
|
||
}
|
||
.voice-controls {
|
||
padding: 0 58rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 30rpx;
|
||
background: transparent;
|
||
}
|
||
.controls-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
align-items: end;
|
||
column-gap: 44rpx;
|
||
}
|
||
.controls-row-bottom {
|
||
align-items: end;
|
||
}
|
||
.control-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 18rpx;
|
||
}
|
||
.control-btn,
|
||
.hangup-btn {
|
||
width: 150rpx;
|
||
height: 150rpx;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
}
|
||
.white-btn {
|
||
background: rgba(255, 255, 255, 0.96);
|
||
box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.18);
|
||
}
|
||
.dark-btn {
|
||
width: 132rpx;
|
||
height: 132rpx;
|
||
background: rgba(22, 43, 105, 0.86);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.06);
|
||
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04);
|
||
}
|
||
.control-btn.disabled {
|
||
opacity: 0.94;
|
||
}
|
||
.control-btn.active {
|
||
background: linear-gradient(180deg, #4f8dff, #2563eb);
|
||
border-color: rgba(79, 141, 255, 0.55);
|
||
}
|
||
.hangup-btn {
|
||
width: 150rpx;
|
||
height: 150rpx;
|
||
background: #ff2f3f;
|
||
box-shadow: 0 14rpx 36rpx rgba(255, 47, 63, 0.38);
|
||
}
|
||
.control-image {
|
||
width: 170rpx;
|
||
height: 170rpx;
|
||
}
|
||
.dark-btn .control-image {
|
||
width: 150rpx;
|
||
height: 150rpx;
|
||
}
|
||
.text-icon-btn .control-image {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
}
|
||
.text-icon-btn {
|
||
color: #ffffff;
|
||
}
|
||
.text-icon {
|
||
font-size: 54rpx;
|
||
line-height: 1;
|
||
color: #ffffff;
|
||
font-weight: 700;
|
||
}
|
||
.hangup-image {
|
||
width: 150rpx;
|
||
height: 150rpx;
|
||
}
|
||
.control-label {
|
||
font-size: 24rpx;
|
||
line-height: 1;
|
||
color: rgba(255, 255, 255, 0.82);
|
||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||
}
|
||
.loading {
|
||
opacity: 0.68;
|
||
}
|
||
.snapshot-preview-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
z-index: 99;
|
||
background: rgba(0, 0, 0, 0.78);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 36rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
.snapshot-preview-panel {
|
||
width: 100%;
|
||
max-width: 720rpx;
|
||
max-height: 88vh;
|
||
border-radius: 24rpx;
|
||
background: #111827;
|
||
padding: 24rpx;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20rpx;
|
||
}
|
||
.snapshot-preview-image {
|
||
width: 100%;
|
||
height: 58vh;
|
||
border-radius: 16rpx;
|
||
background: #000000;
|
||
}
|
||
.snapshot-preview-tip {
|
||
color: rgba(255, 255, 255, 0.82);
|
||
font-size: 24rpx;
|
||
line-height: 1.45;
|
||
text-align: center;
|
||
}
|
||
.snapshot-preview-actions {
|
||
display: flex;
|
||
gap: 18rpx;
|
||
}
|
||
.snapshot-preview-btn {
|
||
flex: 1;
|
||
height: 72rpx;
|
||
line-height: 72rpx;
|
||
border-radius: 999rpx;
|
||
color: #ffffff;
|
||
background: #2563eb;
|
||
font-size: 26rpx;
|
||
}
|
||
.snapshot-preview-btn.secondary {
|
||
color: rgba(255, 255, 255, 0.86);
|
||
background: rgba(255, 255, 255, 0.14);
|
||
}
|
||
.clean-video-tile :deep(.footer) {
|
||
display: none;
|
||
}
|
||
.clean-video-tile :deep(.tag-self) {
|
||
display: none;
|
||
}
|
||
.clean-video-tile :deep(.tag-host) {
|
||
display: none;
|
||
}
|
||
</style>
|