Files
EchoChat/frontend/src/pages/cloud-law/video-call.vue
2026-06-04 15:35:19 +08:00

1595 lines
50 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>
<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.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] 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>