视频会议
This commit is contained in:
@@ -1,221 +1,228 @@
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "EchoChat",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/register",
|
||||
"style": {
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/sso",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "联系人",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/request",
|
||||
"style": {
|
||||
"navigationBarTitleText": "好友申请"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "好友详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索好友"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/groups",
|
||||
"style": {
|
||||
"navigationBarTitleText": "好友分组"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/blacklist",
|
||||
"style": {
|
||||
"navigationBarTitleText": "黑名单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索消息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/read-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "已读详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "群聊",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "创建群聊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "群设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/members",
|
||||
"style": {
|
||||
"navigationBarTitleText": "群成员"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/invite",
|
||||
"style": {
|
||||
"navigationBarTitleText": "邀请入群"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/join-requests",
|
||||
"style": {
|
||||
"navigationBarTitleText": "入群申请"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索群聊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/debug",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议调试(Task 9)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "创建会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/join",
|
||||
"style": {
|
||||
"navigationBarTitleText": "加入会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设备预览"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/room",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议进行中",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/recordings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议录制"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "通知中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#94A3B8",
|
||||
"selectedColor": "#2563EB",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{ "pagePath": "pages/chat/index", "text": "消息" },
|
||||
{ "pagePath": "pages/contact/index", "text": "联系人" },
|
||||
{ "pagePath": "pages/meeting/index", "text": "会议" },
|
||||
{ "pagePath": "pages/profile/index", "text": "我的" }
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "EchoChat",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"backgroundColor": "#F8FAFC"
|
||||
}
|
||||
}
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "EchoChat",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/register",
|
||||
"style": {
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/sso",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "联系人",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/request",
|
||||
"style": {
|
||||
"navigationBarTitleText": "好友申请"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "好友详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索好友"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/groups",
|
||||
"style": {
|
||||
"navigationBarTitleText": "好友分组"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/contact/blacklist",
|
||||
"style": {
|
||||
"navigationBarTitleText": "黑名单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索消息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/read-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "已读详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/conversation",
|
||||
"style": {
|
||||
"navigationBarTitleText": "群聊",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "创建群聊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "群设置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/members",
|
||||
"style": {
|
||||
"navigationBarTitleText": "群成员"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/invite",
|
||||
"style": {
|
||||
"navigationBarTitleText": "邀请入群"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/join-requests",
|
||||
"style": {
|
||||
"navigationBarTitleText": "入群申请"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/group/search",
|
||||
"style": {
|
||||
"navigationBarTitleText": "搜索群聊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/debug",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议调试(Task 9)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/create",
|
||||
"style": {
|
||||
"navigationBarTitleText": "创建会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/join",
|
||||
"style": {
|
||||
"navigationBarTitleText": "加入会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/preview",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设备预览"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/room",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议进行中",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/cloud-law/video-call",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频咨询",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/meeting/recordings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会议录制"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "通知中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#94A3B8",
|
||||
"selectedColor": "#2563EB",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{ "pagePath": "pages/chat/index", "text": "消息" },
|
||||
{ "pagePath": "pages/contact/index", "text": "联系人" },
|
||||
{ "pagePath": "pages/meeting/index", "text": "会议" },
|
||||
{ "pagePath": "pages/profile/index", "text": "我的" }
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "EchoChat",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"backgroundColor": "#F8FAFC"
|
||||
}
|
||||
}
|
||||
|
||||
623
frontend/src/pages/cloud-law/video-call.vue
Normal file
623
frontend/src/pages/cloud-law/video-call.vue
Normal file
@@ -0,0 +1,623 @@
|
||||
<template>
|
||||
<view class="cloud-law-call">
|
||||
<view v-if="!isReady" 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">
|
||||
<view class="remote-stage">
|
||||
<VideoTile
|
||||
v-if="primaryTile"
|
||||
class="main-video"
|
||||
: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-if="selfTile" class="self-preview">
|
||||
<VideoTile
|
||||
class="self-video"
|
||||
:user-id="selfTile.userId"
|
||||
:name="selfTile.name"
|
||||
:is-local="selfTile.isLocal"
|
||||
:is-host="selfTile.isHost"
|
||||
:audio-enabled="selfTile.audioEnabled"
|
||||
:video-enabled="selfTile.videoEnabled"
|
||||
:audio-track="selfTile.audioTrack"
|
||||
:video-track="selfTile.videoTrack"
|
||||
:is-speaking="!!speakingMap[selfTile.userId]"
|
||||
:compact="true"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="top-info">
|
||||
<view>
|
||||
<text class="call-title">视频咨询</text>
|
||||
<text class="call-code">{{ formattedCode }}</text>
|
||||
</view>
|
||||
<view class="status-pill" :class="stateClass">
|
||||
<view class="status-dot"></view>
|
||||
<text>{{ stateText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="timer">{{ durationText }}</view>
|
||||
|
||||
<view class="controls">
|
||||
<view class="control-item" @click="onCamToggle">
|
||||
<view class="control-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }">
|
||||
<text class="control-icon">{{ videoEnabled ? '摄' : '关' }}</text>
|
||||
</view>
|
||||
<text class="control-label">摄像头{{ videoEnabled ? '已开' : '已关' }}</text>
|
||||
</view>
|
||||
|
||||
<view class="control-item" @click="hangup">
|
||||
<view class="hangup-btn" :class="{ loading: leaving }">
|
||||
<text class="hangup-icon">挂</text>
|
||||
</view>
|
||||
<text class="control-label">挂断</text>
|
||||
</view>
|
||||
|
||||
<view class="control-item" @click="onMicToggle">
|
||||
<view class="control-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }">
|
||||
<text class="control-icon">{{ audioEnabled ? '麦' : '静' }}</text>
|
||||
</view>
|
||||
<text class="control-label">麦克风{{ audioEnabled ? '已开' : '已关' }}</text>
|
||||
</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'
|
||||
|
||||
const meetingStore = useMeetingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const rawCode = ref('')
|
||||
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)
|
||||
let timerHandle = null
|
||||
let stateStopWatch = null
|
||||
|
||||
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 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 primaryTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || selfTile.value)
|
||||
|
||||
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 joinMeeting = async () => {
|
||||
if (joining.value) return
|
||||
const code = formattedCode.value
|
||||
if (!digitsOf(code)) {
|
||||
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(() => {})
|
||||
}
|
||||
meetingStore.devicePreview = {
|
||||
audioDeviceId: '',
|
||||
videoDeviceId: '',
|
||||
speakerDeviceId: '',
|
||||
displayName: userStore.userInfo?.nickname || userStore.userInfo?.username || '',
|
||||
startAudio: true,
|
||||
startVideo: true
|
||||
}
|
||||
await meetingStore.joinAndEnter(code, '', {
|
||||
startAudio: true,
|
||||
startVideo: true,
|
||||
audioDeviceId: '',
|
||||
videoDeviceId: ''
|
||||
})
|
||||
startTimer()
|
||||
} catch (err) {
|
||||
joinError.value = err?.message || '视频咨询接入失败'
|
||||
console.error('[CloudLawVideoCall] join failed', err)
|
||||
} finally {
|
||||
joining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMicToggle = async () => {
|
||||
if (audioLoading.value || leaving.value) return
|
||||
audioLoading.value = true
|
||||
try {
|
||||
if (audioEnabled.value) {
|
||||
await meetingStore.stopLocalAudio()
|
||||
} else {
|
||||
await meetingStore.startLocalAudio()
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '麦克风操作失败', icon: 'none' })
|
||||
} finally {
|
||||
audioLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onCamToggle = async () => {
|
||||
if (videoLoading.value || leaving.value) return
|
||||
videoLoading.value = true
|
||||
try {
|
||||
if (videoEnabled.value) {
|
||||
await meetingStore.stopLocalVideo()
|
||||
} else {
|
||||
await meetingStore.startLocalVideo()
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err?.message || '摄像头操作失败', icon: 'none' })
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hangup = async () => {
|
||||
if (leaving.value) return
|
||||
leaving.value = true
|
||||
try {
|
||||
await meetingStore.leave()
|
||||
} catch (err) {
|
||||
console.warn('[CloudLawVideoCall] leave failed', err)
|
||||
} finally {
|
||||
ended.value = true
|
||||
leaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(query => {
|
||||
rawCode.value = query?.code || query?.roomCode || ''
|
||||
joinMeeting()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
stateStopWatch = watch(() => meetingStore.localState, s => {
|
||||
if (s === MEETING_LOCAL_STATE_CONNECTED) {
|
||||
startTimer()
|
||||
}
|
||||
if (s === MEETING_LOCAL_STATE_ENDED) {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
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;
|
||||
background: linear-gradient(180deg, #32175f 0%, #071025 38%, #050b1d 100%);
|
||||
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;
|
||||
}
|
||||
.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: 24rpx;
|
||||
bottom: 218rpx;
|
||||
width: 210rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
border: 2rpx solid rgba(108, 151, 255, 0.72);
|
||||
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;
|
||||
}
|
||||
.top-info {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: calc(26rpx + env(safe-area-inset-top)) 28rpx 40rpx;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
box-sizing: border-box;
|
||||
z-index: 6;
|
||||
}
|
||||
.call-title {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.call-code {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(21, 128, 61, 0.72);
|
||||
color: #baf7d0;
|
||||
font-size: 22rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-pill.connecting,
|
||||
.status-pill.reconnecting {
|
||||
background: rgba(37, 99, 235, 0.72);
|
||||
color: #dbeafe;
|
||||
}
|
||||
.status-pill.ended,
|
||||
.status-pill.leaving {
|
||||
background: rgba(107, 114, 128, 0.72);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.status-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 12rpx currentColor;
|
||||
}
|
||||
.timer {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 196rpx;
|
||||
transform: translateX(-50%);
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
z-index: 7;
|
||||
}
|
||||
.controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 34rpx 44rpx calc(34rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(0deg, rgba(4, 10, 27, 0.96) 0%, rgba(4, 10, 27, 0.72) 72%, rgba(4, 10, 27, 0) 100%);
|
||||
box-sizing: border-box;
|
||||
z-index: 8;
|
||||
}
|
||||
.control-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
min-width: 150rpx;
|
||||
}
|
||||
.control-btn,
|
||||
.hangup-btn {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.control-btn {
|
||||
background: rgba(24, 64, 140, 0.9);
|
||||
}
|
||||
.control-btn.disabled {
|
||||
background: rgba(18, 32, 70, 0.94);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.hangup-btn {
|
||||
background: #ff2727;
|
||||
box-shadow: 0 10rpx 34rpx rgba(255, 39, 39, 0.38);
|
||||
}
|
||||
.control-icon,
|
||||
.hangup-icon {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.control-label {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
.loading {
|
||||
opacity: 0.68;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user