推广商用户数据权限

This commit is contained in:
duoaohui
2026-05-24 23:44:28 +08:00
parent 4878120723
commit 5c638b0478
2 changed files with 262 additions and 25 deletions

View File

@@ -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;

View File

@@ -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,