From 5c638b0478816b9ad358632006e8e96c5cb6f239 Mon Sep 17 00:00:00 2001
From: duoaohui <928970622@qq.com>
Date: Sun, 24 May 2026 23:44:28 +0800
Subject: [PATCH] =?UTF-8?q?=E6=8E=A8=E5=B9=BF=E5=95=86=E7=94=A8=E6=88=B7?=
=?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=9D=83=E9=99=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/pages/cloud-law/video-call.vue | 249 ++++++++++++++++++--
frontend/src/store/meeting.js | 38 +++
2 files changed, 262 insertions(+), 25 deletions(-)
diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue
index 95c875e..f772d03 100644
--- a/frontend/src/pages/cloud-law/video-call.vue
+++ b/frontend/src/pages/cloud-law/video-call.vue
@@ -71,25 +71,50 @@
{{ durationText }}
-
-
-
+
+
+
+
+
+ 摄像头{{ videoEnabled ? '已开' : '已关' }}
+
+
+
+
+
+
+ 扬声器{{ speakerMuted ? '已关' : '已开' }}
+
+
+
+
+
+
+ 麦克风{{ audioEnabled ? '已开' : '已关' }}
- 摄像头{{ videoEnabled ? '已开' : '已关' }}
-
-
-
+
+
+
+ 截
+
+ 截屏
- 挂断
-
-
-
-
+
+
+
+
+ 挂断
+
+
+
+
+ ↻
+
+ 镜头翻转
- 麦克风{{ audioEnabled ? '已开' : '已关' }}
@@ -118,11 +143,15 @@ 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('')
@@ -136,6 +165,10 @@ 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 digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9)
const sameUser = (a, b) => String(a || '') === String(b || '')
@@ -155,6 +188,7 @@ 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 || '在'
@@ -319,6 +353,152 @@ const startTimer = () => {
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
@@ -357,6 +537,7 @@ const joinMeeting = async () => {
await meetingStore.joinAndEnter(code, '', prefs)
startTimer()
scheduleVideoRetry()
+ startCommandPolling()
} catch (err) {
joinError.value = err?.message || '视频咨询接入失败'
console.error('[CloudLawVideoCall] join failed', err)
@@ -446,11 +627,11 @@ const acquireAudioTrack = async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
return stream.getAudioTracks()[0]
}
-const acquireVideoTrack = async () => {
+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: 'user' }
+ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode }
})
return stream.getVideoTracks()[0]
}
@@ -483,7 +664,7 @@ const onCamToggle = async () => {
if (meetingStore.localProducers?.video) {
await meetingStore.setLocalVideoMuted(videoEnabled.value)
} else {
- const track = await acquireVideoTrack()
+ const track = await acquireVideoTrack(cameraFacing.value)
await meetingStore.startLocalVideo(undefined, track)
}
localMediaError.value = ''
@@ -498,6 +679,7 @@ const onCamToggle = async () => {
const hangup = async () => {
if (leaving.value) return
leaving.value = true
+ stopCommandPolling()
try {
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host,
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。
@@ -522,6 +704,8 @@ const hangup = async () => {
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()
@@ -532,8 +716,10 @@ onMounted(() => {
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' })
@@ -555,9 +741,11 @@ onBeforeUnmount(() => {
clearTimeout(videoRetryHandle)
videoRetryHandle = null
}
+ stopCommandPolling()
})
onUnload(() => {
+ stopCommandPolling()
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
meetingStore.leave().catch(() => {})
}
@@ -795,24 +983,26 @@ onUnload(() => {
}
.video-controls {
padding: 0 58rpx;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- align-items: end;
- column-gap: 44rpx;
+ 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;
- background: transparent;
}
-.voice-control-col {
- display: flex;
- flex-direction: column;
- gap: 36rpx;
+.controls-row-bottom {
+ align-items: end;
}
.control-item {
display: flex;
@@ -858,6 +1048,15 @@ onUnload(() => {
width: 150rpx;
height: 150rpx;
}
+.text-icon-btn {
+ color: #ffffff;
+}
+.text-icon {
+ font-size: 54rpx;
+ line-height: 1;
+ color: #ffffff;
+ font-weight: 700;
+}
.hangup-image {
width: 150rpx;
height: 150rpx;
diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js
index 0e1ff61..5e80772 100644
--- a/frontend/src/store/meeting.js
+++ b/frontend/src/store/meeting.js
@@ -1494,6 +1494,43 @@ export const useMeetingStore = defineStore('meeting', () => {
return producer ? producer.track : null
}
+ const switchLocalCameraFacing = async (facingMode = 'user') => {
+ if (!_engine || !localProducers.video) return false
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ throw new Error('当前环境不支持切换摄像头')
+ }
+ const oldProducerId = localProducers.video
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: false,
+ video: {
+ width: { ideal: 640 },
+ height: { ideal: 480 },
+ frameRate: { ideal: 15, max: 24 },
+ facingMode
+ }
+ })
+ const track = stream.getVideoTracks()[0]
+ if (!track) {
+ try { stream.getTracks().forEach(t => t.stop()) } catch {}
+ throw new Error('未获取到摄像头画面')
+ }
+ let newProducer = null
+ try {
+ newProducer = await _engine.produce({ kind: 'video', track })
+ localProducers.video = newProducer.id
+ localVideoEnabled.value = true
+ try { await _engine.closeProducer(oldProducerId) } catch (err) { _log('warn', '[Meeting] 切换摄像头时关闭旧 Producer 失败', err) }
+ _broadcastSelfState({ video_enabled: true })
+ return true
+ } catch (err) {
+ try { track.stop() } catch {}
+ if (newProducer && newProducer.id) {
+ try { await _engine.closeProducer(newProducer.id) } catch {}
+ }
+ throw err
+ }
+ }
+
// ==================== Actions:聊天/管理 ====================
/**
@@ -1649,6 +1686,7 @@ export const useMeetingStore = defineStore('meeting', () => {
stopLocalVideo,
setLocalAudioMuted,
setLocalVideoMuted,
+ switchLocalCameraFacing,
startScreenShare,
stopScreenShare,
recording,