Files
EchoChat/frontend/src/pages/cloud-law/video-call.vue
2026-05-25 21:08:59 +08:00

1093 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
</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">
<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="hangup"></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 class="control-item" @click="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>
</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 cloudLawApiBase = ref('')
const callMode = ref('video')
const joining = ref(false)
const joinError = ref('')
const audioLoading = ref(false)
const videoLoading = 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 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 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 || ''}`
})
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 || ''}`)
})
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 (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 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 = () => {
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 takeSnapshot = () => {
// #ifdef H5
try {
const video = document.querySelector('.cloud-law-call .main-video video') || document.querySelector('.cloud-law-call video')
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 a = document.createElement('a')
a.href = canvas.toDataURL('image/png')
a.download = `cloud-law-video-${Date.now()}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
uni.showToast({ title: '截屏已保存', icon: 'success' })
} catch (err) {
console.warn('[CloudLawVideoCall] snapshot failed', err)
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 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 hangup = async () => {
if (leaving.value) return
leaving.value = true
stopCommandPolling()
try {
// 云律视频咨询:发起人(小程序端用户)在 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()
} catch (err) {
console.warn('[CloudLawVideoCall] endMeeting failed, fallback leave', err)
// endMeeting 失败时兜底 leave至少自己先退出避免卡死
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
} finally {
ended.value = true
leaving.value = false
}
}
onLoad(query => {
rawCode.value = query?.code || query?.roomCode || ''
callId.value = query?.callId || query?.call_id || ''
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
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()
const reason = meetingStore.lastEndedReason
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束'
uni.showToast({ title: label, icon: 'none' })
ended.value = true
}
}, { immediate: true })
})
onBeforeUnmount(() => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
if (stateStopWatch) {
stateStopWatch()
stateStopWatch = null
}
if (videoRetryHandle) {
clearTimeout(videoRetryHandle)
videoRetryHandle = null
}
stopCommandPolling()
})
onUnload(() => {
stopCommandPolling()
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-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;
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;
}
.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;
}
.clean-video-tile :deep(.footer) {
display: none;
}
.clean-video-tile :deep(.tag-self) {
display: none;
}
.clean-video-tile :deep(.tag-host) {
display: none;
}
</style>