推广商用户数据权限
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
<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>
|
||||
@@ -78,6 +79,29 @@
|
||||
<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">
|
||||
<text 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>
|
||||
@@ -85,11 +109,12 @@
|
||||
<text class="control-label">挂断</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 class="control-item" @click="flipCamera">
|
||||
<view class="control-btn dark-btn text-icon-btn">
|
||||
<text class="text-icon">↻</text>
|
||||
</view>
|
||||
<text class="control-label">镜头翻转</text>
|
||||
</view>
|
||||
<text class="control-label">麦克风{{ audioEnabled ? '已开' : '已关' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user