1408 lines
44 KiB
Vue
1408 lines
44 KiB
Vue
<!--
|
||
会议主页面(Task 11)
|
||
|
||
职责:
|
||
- 组装 VideoGrid / VideoTile / MeetingToolbar / MemberPanel / InviteDialog / NetworkBadge
|
||
- 把 Pinia 的 participants + remoteConsumers + 本地 track 投影成 Grid 所需的 tile 列表
|
||
- 处理麦/摄/邀请/成员/挂断交互,挂断时提供主持人"结束会议 / 仅自己离开"双选
|
||
|
||
数据流:
|
||
- 进入本页前必须已通过 preview.vue 完成 createAndEnter / joinAndEnter
|
||
- onLoad 读取 query.code,若 store 未在会或 code 不匹配则回退到 /pages/meeting/join
|
||
-->
|
||
<template>
|
||
<view class="room">
|
||
<!-- 顶部信息栏 -->
|
||
<view class="top-bar">
|
||
<view class="top-left">
|
||
<view v-if="showCloudLawBack" class="cloud-law-back" @click="onCloudLawBack">
|
||
<text class="cloud-law-back-icon">‹</text>
|
||
</view>
|
||
<view class="top-left-info">
|
||
<text class="meeting-title">{{ meetingTitle }}</text>
|
||
<view class="code-pill" @click="openInvite">
|
||
<text class="code-text">{{ formattedCode }}</text>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||
</svg>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="top-right">
|
||
<view class="state-pill" :class="stateClass">
|
||
<view class="state-dot"></view>
|
||
<text>{{ stateText }}</text>
|
||
</view>
|
||
<!-- Phase B 录制指示器:所有人可见,点击对 host 触发停止;非 host 仅展示 -->
|
||
<view
|
||
v-if="isRecording || recordingLoading"
|
||
class="recording-indicator"
|
||
:class="{ uploading: recordingLoading && !isRecording }"
|
||
:title="recordingHintText"
|
||
>
|
||
<view class="rec-dot"></view>
|
||
<text class="rec-text">{{ isRecording ? 'REC' : '上传中' }}</text>
|
||
</view>
|
||
<NetworkBadge :level="networkLevel" />
|
||
<text class="timer">{{ durationText }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 主区:左视频 + 右侧聊天面板(仅 chatVisible 时侧栏展开) -->
|
||
<view class="body">
|
||
<view ref="videoCol" class="video-col" :class="{ 'has-screen': !!screenTile }">
|
||
<!-- Phase 3:有屏幕共享时,屏幕画面独占大画面区,参与者网格降级为底部横向条带 -->
|
||
<view v-if="screenTile" class="screen-stage">
|
||
<view class="screen-stage-tile">
|
||
<VideoTile
|
||
:user-id="screenTile.userId"
|
||
:name="screenTile.name + '(屏幕共享)'"
|
||
:is-local="screenTile.isLocal"
|
||
:is-host="screenTile.isHost"
|
||
:audio-enabled="false"
|
||
:video-enabled="true"
|
||
:audio-track="null"
|
||
:video-track="screenTile.videoTrack"
|
||
video-fit="contain"
|
||
:is-speaking="false"
|
||
/>
|
||
</view>
|
||
<view class="screen-strip">
|
||
<view v-for="t in stripTiles" :key="t.key" class="strip-cell">
|
||
<VideoTile
|
||
:user-id="t.userId"
|
||
:name="t.name"
|
||
:is-local="t.isLocal"
|
||
:is-host="t.isHost"
|
||
:audio-enabled="t.audioEnabled"
|
||
:video-enabled="t.videoEnabled"
|
||
:audio-track="t.audioTrack"
|
||
:video-track="t.videoTrack"
|
||
:is-speaking="!!speakingMap[t.userId]"
|
||
:compact="true"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<VideoGrid v-else :tiles="gridTiles">
|
||
<template #tile="{ tile }">
|
||
<VideoTile
|
||
:user-id="tile.userId"
|
||
:name="tile.name"
|
||
:is-local="tile.isLocal"
|
||
:is-host="tile.isHost"
|
||
:audio-enabled="tile.audioEnabled"
|
||
:video-enabled="tile.videoEnabled"
|
||
:audio-track="tile.audioTrack"
|
||
:video-track="tile.videoTrack"
|
||
:is-speaking="!!speakingMap[tile.userId]"
|
||
/>
|
||
</template>
|
||
</VideoGrid>
|
||
|
||
<!-- 自视频浮窗(Task 15):仅桌面端浮窗模式开启时显示;图钉切换后本地 tile 回到网格里 -->
|
||
<SelfVideoFloat
|
||
v-if="floatEnabled && selfTile"
|
||
:user-id="selfTile.userId"
|
||
:name="selfTile.name"
|
||
: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]"
|
||
:container="videoCol"
|
||
@pin-click="togglePin"
|
||
/>
|
||
</view>
|
||
<view class="chat-col" :class="{ open: chatVisible }">
|
||
<ChatPanel
|
||
:visible="chatVisible"
|
||
:messages="chatMessages"
|
||
:current-user-id="myUserId"
|
||
:display-name-map="displayNameMap"
|
||
:has-more="chatHasMore"
|
||
:loading-more="chatLoadingMore"
|
||
@close="closeChat"
|
||
@send="onChatSend"
|
||
@load-more="onChatLoadMore"
|
||
@request-initial-load="onChatRequestInitialLoad"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部工具条 -->
|
||
<MeetingToolbar
|
||
:audio-enabled="audioEnabled"
|
||
:video-enabled="videoEnabled"
|
||
:audio-loading="audioLoading"
|
||
:video-loading="videoLoading"
|
||
:member-count="tiles.length"
|
||
:unread-chat-count="unreadChatCount"
|
||
:allow-chat="allowChat"
|
||
:all-muted="allMuted"
|
||
:screen-sharing="screenSharingByMe"
|
||
:screen-loading="screenLoading"
|
||
:screen-disabled="screenDisabled"
|
||
:screen-disabled-reason="screenDisabledReason"
|
||
:show-recording="isHost"
|
||
:recording="isRecording"
|
||
:recording-loading="recordingLoading"
|
||
:replace-chat-with-screen-share="replaceChatWithScreenShare"
|
||
@mic-toggle="onMicToggle"
|
||
@cam-toggle="onCamToggle"
|
||
@screen-toggle="onScreenToggle"
|
||
@recording-toggle="onRecordingToggle"
|
||
@invite="openInvite"
|
||
@members="openMembers"
|
||
@chat="openChat"
|
||
@leave="onLeaveClick"
|
||
/>
|
||
|
||
<!-- 成员抽屉 -->
|
||
<MemberPanel
|
||
:visible="memberVisible"
|
||
:participants="participants"
|
||
:current-user-id="myUserId"
|
||
:host-id="hostId"
|
||
:is-host="isHost"
|
||
:display-name-map="displayNameMap"
|
||
@close="memberVisible = false"
|
||
@kick="onKickMember"
|
||
@transfer-host="onTransferHost"
|
||
@mute-member="onMuteMember"
|
||
/>
|
||
|
||
<!-- 邀请弹窗 -->
|
||
<InviteDialog
|
||
:visible="inviteVisible"
|
||
:room-code="roomCode"
|
||
:password="invitePassword"
|
||
:meeting-title="meetingTitle"
|
||
:join-link="joinLink"
|
||
@close="inviteVisible = false"
|
||
/>
|
||
|
||
<!-- 离会确认弹窗 -->
|
||
<view v-if="leaveDialogVisible" class="leave-mask" @click.self="leaveDialogVisible = false">
|
||
<view class="leave-modal">
|
||
<view class="leave-title">确认{{ isHost ? '结束或离开' : '离开' }}会议?</view>
|
||
<view class="leave-desc">
|
||
{{ isHost ? '作为主持人,你可以选择结束会议(所有成员都会离开),或仅自己离开(自动转让给其他成员)。' : '离开后可通过会议号重新加入。' }}
|
||
</view>
|
||
<view class="leave-actions">
|
||
<button class="leave-btn leave-cancel" @click="leaveDialogVisible = false">取消</button>
|
||
<button v-if="isHost" class="leave-btn leave-leave" @click="confirmLeaveSelf">仅自己离开</button>
|
||
<button class="leave-btn leave-danger" @click="confirmEndOrLeave">
|
||
{{ isHost ? '结束会议' : '离开会议' }}
|
||
</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,
|
||
MEETING_RECORDING_STATUS_RECORDING,
|
||
MEETING_RECORDING_STATUS_UPLOADING,
|
||
MEETING_RECORDING_STATUS_READY,
|
||
MEETING_RECORDING_STATUS_FAILED,
|
||
MEETING_RECORDING_STATUS_LABEL
|
||
} from '@/constants/meeting'
|
||
|
||
import VideoGrid from '@/components/meeting/VideoGrid.vue'
|
||
import VideoTile from '@/components/meeting/VideoTile.vue'
|
||
import MeetingToolbar from '@/components/meeting/MeetingToolbar.vue'
|
||
import MemberPanel from '@/components/meeting/MemberPanel.vue'
|
||
import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
||
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
||
import ChatPanel from '@/components/meeting/ChatPanel.vue'
|
||
import SelfVideoFloat from '@/components/meeting/SelfVideoFloat.vue'
|
||
import {
|
||
isCloudLawEmbed,
|
||
isCloudLawLawRole,
|
||
minimizeCloudLawMeeting,
|
||
notifyCloudLawCallEnded,
|
||
getMiniProgram
|
||
} from '@/utils/cloudLawMiniProgram'
|
||
import {
|
||
isCloudLawLawRoleInMeeting,
|
||
isCloudLawPcIframe,
|
||
notifyCloudLawBackendEnd,
|
||
notifyCloudLawParentCallEnded,
|
||
queryCloudLawCallStatus
|
||
} from '@/utils/cloudLawVoiceCall'
|
||
|
||
const meetingStore = useMeetingStore()
|
||
const userStore = useUserStore()
|
||
|
||
/** 云律律师端:工具栏用共享屏幕替换聊天 */
|
||
const cloudLawRole = ref('')
|
||
const cloudLawCallId = ref('')
|
||
const cloudLawApiBase = ref('')
|
||
const minimizingToMiniProgram = ref(false)
|
||
const replaceChatWithScreenShare = computed(() => String(cloudLawRole.value).toLowerCase() === 'law')
|
||
const showCloudLawBack = computed(() => isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value))
|
||
|
||
const memberVisible = ref(false)
|
||
const inviteVisible = ref(false)
|
||
const chatVisible = ref(false)
|
||
const leaveDialogVisible = ref(false)
|
||
const audioLoading = ref(false)
|
||
const videoLoading = ref(false)
|
||
const screenLoading = ref(false)
|
||
const chatLoadingMore = ref(false)
|
||
const networkLevel = ref(4) // 持续接入 getStats 后动态更新(Task 16 进一步收敛)
|
||
const startedAt = ref(0)
|
||
const nowTs = ref(Date.now())
|
||
let timerHandle = null
|
||
let viewportCleanup = null
|
||
|
||
/** 视频区容器引用:传给 SelfVideoFloat 计算吸附边界 */
|
||
const videoCol = ref(null)
|
||
|
||
/** 说话者映射由 meeting store 探测出(Task 15),浮窗和网格都订阅它来驱动流光动效 */
|
||
const speakingMap = computed(() => meetingStore.speakingMap)
|
||
|
||
/** 全员静音标记,用于 Task 15 工具栏冷色氛围(允许单人时恒为 false) */
|
||
const allMuted = computed(() => meetingStore.isAllMuted)
|
||
|
||
// ============ 基础映射 ============
|
||
|
||
const myUserId = computed(() => userStore.userInfo?.id || 0)
|
||
const isHost = computed(() => meetingStore.isHost)
|
||
/**
|
||
* 修正 participants:本地用户的 audio_enabled/video_enabled 优先使用 store
|
||
* 本地状态(localAudioEnabled/localVideoEnabled),因为后端不会给自己广播
|
||
* member.state.changed,若只依赖 REST 详情易和实际操作脱节
|
||
*/
|
||
const participants = computed(() => {
|
||
const uid = myUserId.value
|
||
return meetingStore.activeParticipants.map(p => {
|
||
if (p.user_id !== uid) return p
|
||
return {
|
||
...p,
|
||
audio_enabled: meetingStore.localAudioEnabled,
|
||
video_enabled: meetingStore.localVideoEnabled
|
||
}
|
||
})
|
||
})
|
||
const hostId = computed(() => meetingStore.currentRoom?.host_id || 0)
|
||
const roomCode = computed(() => meetingStore.currentRoom?.room_code || '')
|
||
const meetingTitle = computed(() => meetingStore.currentRoom?.title || '会议')
|
||
const allowChat = computed(() => {
|
||
const settings = meetingStore.currentRoom?.settings
|
||
if (!settings) return true
|
||
try {
|
||
const parsed = typeof settings === 'string' ? JSON.parse(settings) : settings
|
||
return parsed?.allow_chat !== false
|
||
} catch {
|
||
return true
|
||
}
|
||
})
|
||
const invitePassword = computed(() => meetingStore.draftCreatePayload?.password || '')
|
||
const chatMessages = computed(() => meetingStore.chatMessages)
|
||
const chatHasMore = computed(() => meetingStore.chatHasMore)
|
||
const unreadChatCount = computed(() => meetingStore.chatUnreadCount)
|
||
|
||
const formattedCode = computed(() => {
|
||
const d = String(roomCode.value || '').replace(/\D/g, '')
|
||
if (d.length !== 9) return d
|
||
return `${d.slice(0, 3)}-${d.slice(3, 6)}-${d.slice(6)}`
|
||
})
|
||
|
||
const joinLink = computed(() => {
|
||
// #ifdef H5
|
||
const base = `${window.location.origin}${window.location.pathname}`
|
||
return `${base}#/pages/meeting/join?code=${roomCode.value}`
|
||
// #endif
|
||
// #ifndef H5
|
||
return `echochat://meeting/${roomCode.value}`
|
||
// #endif
|
||
})
|
||
|
||
// ============ 状态 pill ============
|
||
|
||
const stateClass = computed(() => {
|
||
switch (meetingStore.localState) {
|
||
case MEETING_LOCAL_STATE_CONNECTED: return 'state-connected'
|
||
case MEETING_LOCAL_STATE_RECONNECTING: return 'state-reconnecting'
|
||
case MEETING_LOCAL_STATE_CONNECTING:
|
||
case MEETING_LOCAL_STATE_JOINING: return 'state-connecting'
|
||
case MEETING_LOCAL_STATE_LEAVING: return 'state-leaving'
|
||
case MEETING_LOCAL_STATE_ENDED: return 'state-ended'
|
||
default: return 'state-idle'
|
||
}
|
||
})
|
||
const stateText = computed(() => {
|
||
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 audioEnabled = computed(() => meetingStore.localAudioEnabled)
|
||
const videoEnabled = computed(() => meetingStore.localVideoEnabled)
|
||
|
||
// ============ Phase 3 屏幕共享 ============
|
||
|
||
/** 当前会议是否已有屏幕共享(任意成员) */
|
||
const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
|
||
/** 屏幕共享者是否就是自己(决定工具栏按钮高亮 + 文案) */
|
||
const screenSharingByMe = computed(() => {
|
||
const owner = meetingStore.screenShare?.ownerUserId
|
||
return !!owner && owner === myUserId.value
|
||
})
|
||
/** H5 桌面浏览器才有 getDisplayMedia;非 H5 / 移动端 / 老内核都禁用按钮 */
|
||
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 ''
|
||
})
|
||
|
||
/**
|
||
* 屏幕共享大画面 tile:当前会议有屏幕共享时返回 tile 描述对象,否则 null
|
||
*
|
||
* track 来源分两路:
|
||
* - 自己共享:从 store.getLocalTrack('screen') 取本地 track
|
||
* - 他人共享:从 store.getRemoteTrack(ownerUid, 'screen') 取远端 Consumer track
|
||
*
|
||
* 触发重算:依赖 screenShare reactive + localTrackVersion + remoteTrackVersion
|
||
*/
|
||
const screenTile = computed(() => {
|
||
const owner = meetingStore.screenShare?.ownerUserId
|
||
if (!owner) return null
|
||
// 触发依赖收集,让 store 内 producer/consumer 变化也能让本 computed 重算
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||
localTrackVersion.value
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||
remoteTrackVersion.value
|
||
|
||
const isLocal = owner === myUserId.value
|
||
const track = isLocal
|
||
? meetingStore.getLocalTrack('screen')
|
||
: meetingStore.getRemoteTrack(owner, 'screen')
|
||
// track 还没就绪时(screen.started 已到但 producer.new 未到 / consumer 未建好)暂不显示大画面,
|
||
// 等下次重算(_onProducerNew 完成后 remoteTrackVersion 变化触发)
|
||
if (!track) return null
|
||
|
||
return {
|
||
key: `screen_${owner}`,
|
||
userId: owner,
|
||
name: isLocal ? '你' : nameOf(owner),
|
||
isLocal,
|
||
isHost: owner === hostId.value,
|
||
videoTrack: track
|
||
}
|
||
})
|
||
|
||
/** 屏幕共享期间,底部条带显示参会者(含本地 camera tile) */
|
||
const stripTiles = computed(() => {
|
||
if (!screenTile.value) return []
|
||
// 直接复用 tiles 列表(含本地);后续可按需限制数量
|
||
return tiles.value
|
||
})
|
||
|
||
/** 监听 store.getLocalTrack 的变化:localProducers 变化即 bump 版本(含屏幕共享 producer) */
|
||
const localTrackVersion = computed(() => {
|
||
const lp = meetingStore.localProducers
|
||
return `${lp.audio || ''}-${lp.video || ''}-${lp.screen || ''}`
|
||
})
|
||
|
||
/** 远端 track 版本:让 tiles 在 slot.audio/slot.video/slot.screen 变化时重新计算 */
|
||
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('|')
|
||
})
|
||
|
||
/** 参与者姓名缓存:user_name 来自后端 DTO */
|
||
const displayNameMap = computed(() => {
|
||
const m = {}
|
||
participants.value.forEach(p => {
|
||
if (p.user_name) m[p.user_id] = p.user_name
|
||
})
|
||
const me = userStore.userInfo
|
||
if (me?.id) m[me.id] = me.nickname || me.username || m[me.id]
|
||
return m
|
||
})
|
||
|
||
const nameOf = (uid) => {
|
||
const me = userStore.userInfo
|
||
if (me?.id === uid) return me.nickname || me.username || `你`
|
||
return displayNameMap.value[uid] || `用户 ${uid}`
|
||
}
|
||
|
||
/** 把 store 状态投影为 VideoGrid 需要的 tile 列表 */
|
||
const tiles = computed(() => {
|
||
// 监听 track 版本让 computed 跟随远端/本地 track 变化重算
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||
localTrackVersion.value
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||
remoteTrackVersion.value
|
||
|
||
const list = []
|
||
const myUid = myUserId.value
|
||
participants.value.forEach(p => {
|
||
if (!p.is_active && p.is_active !== undefined) return
|
||
const isLocal = p.user_id === myUid
|
||
let audioTrack = null
|
||
let videoTrack = null
|
||
if (isLocal) {
|
||
audioTrack = meetingStore.getLocalTrack('audio')
|
||
videoTrack = meetingStore.getLocalTrack('video')
|
||
} else {
|
||
audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||
videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||
}
|
||
// Task 15 回归修复:tile 是否渲染 <video>/<audio> 以 track 实际存在为准
|
||
// 参会者的 audio_enabled/video_enabled 仅作为 MemberPanel 状态图标色彩依据,
|
||
// 不再决定 VideoTile 媒体元素的挂载;否则一旦 state 字段滞后就会屏蔽实际画面
|
||
const tile = {
|
||
key: `u_${p.user_id}`,
|
||
userId: p.user_id,
|
||
name: isLocal ? '你' : nameOf(p.user_id),
|
||
isLocal,
|
||
isHost: p.user_id === hostId.value,
|
||
audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
|
||
videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
|
||
audioTrack,
|
||
videoTrack
|
||
}
|
||
list.push(tile)
|
||
})
|
||
// 若本地用户尚未出现在 participants 里(极端情况:REST 延迟),兜底追加一个本地 tile
|
||
if (myUid && !list.some(t => t.userId === myUid)) {
|
||
list.unshift({
|
||
key: `u_${myUid}`,
|
||
userId: myUid,
|
||
name: '你',
|
||
isLocal: true,
|
||
isHost: hostId.value === myUid,
|
||
audioEnabled: audioEnabled.value,
|
||
videoEnabled: videoEnabled.value,
|
||
audioTrack: meetingStore.getLocalTrack('audio'),
|
||
videoTrack: meetingStore.getLocalTrack('video')
|
||
})
|
||
}
|
||
return list
|
||
})
|
||
|
||
// ============ Task 15 浮窗模式 ============
|
||
|
||
/**
|
||
* 浮窗开关:从 store.uiPrefs 读取,默认桌面端开启浮窗
|
||
* 点击图钉按钮时 togglePin 切换,本地 tile 会在浮窗 <-> 网格间迁移
|
||
*/
|
||
const floatEnabled = computed(() => meetingStore.uiPrefs?.selfVideoFloat !== false)
|
||
|
||
/**
|
||
* 自视频 tile:浮窗模式下从 tiles 中剥离出来单独挂载
|
||
* 若 floatEnabled=false 则返回 null,自视频仍保留在 Grid 里
|
||
*/
|
||
const selfTile = computed(() => {
|
||
if (!floatEnabled.value) return null
|
||
return tiles.value.find(t => t.isLocal) || null
|
||
})
|
||
|
||
/**
|
||
* 网格 tiles:浮窗开启时剔除本地 tile(只渲染远端),否则保持原始
|
||
* 这样 VideoGrid 的 layout-1/2/3/N 档位判断基于远端人数,符合用户认知
|
||
*/
|
||
const gridTiles = computed(() => {
|
||
if (!floatEnabled.value) return tiles.value
|
||
return tiles.value.filter(t => !t.isLocal)
|
||
})
|
||
|
||
const togglePin = () => {
|
||
meetingStore.uiPrefs.selfVideoFloat = !meetingStore.uiPrefs.selfVideoFloat
|
||
}
|
||
|
||
// ============ 事件处理 ============
|
||
|
||
const onMicToggle = async () => {
|
||
audioLoading.value = true
|
||
try {
|
||
if (audioEnabled.value) {
|
||
await meetingStore.stopLocalAudio()
|
||
} else {
|
||
await meetingStore.startLocalAudio(meetingStore.devicePreview?.audioDeviceId)
|
||
}
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
} finally {
|
||
audioLoading.value = false
|
||
}
|
||
}
|
||
|
||
const onCamToggle = async () => {
|
||
videoLoading.value = true
|
||
try {
|
||
if (videoEnabled.value) {
|
||
await meetingStore.stopLocalVideo()
|
||
} else {
|
||
await meetingStore.startLocalVideo(meetingStore.devicePreview?.videoDeviceId)
|
||
}
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
} finally {
|
||
videoLoading.value = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 工具栏共享屏幕切换
|
||
*
|
||
* 状态机:
|
||
* - 未在共享 + 平台支持 + 无他人共享 → 调 startScreenShare
|
||
* - 自己已在共享 → 调 stopScreenShare
|
||
* - 其他场景由 MeetingToolbar.emitIf 拦截,不会进入本函数
|
||
*
|
||
* 容错:getDisplayMedia 用户取消会抛 NotAllowedError,对用户体验友好的提示是"已取消"
|
||
*/
|
||
const onScreenToggle = async () => {
|
||
if (screenLoading.value) return
|
||
screenLoading.value = true
|
||
try {
|
||
if (screenSharingByMe.value) {
|
||
await meetingStore.stopScreenShare()
|
||
} else {
|
||
await meetingStore.startScreenShare()
|
||
}
|
||
} catch (err) {
|
||
// NotAllowedError = 用户在系统选择器中点了取消,静默处理;其他错误才提示
|
||
if (err?.name !== 'NotAllowedError') {
|
||
uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
|
||
}
|
||
} finally {
|
||
screenLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ============ 录制(Phase B) ============
|
||
|
||
/** 当前是否处于"录制中"(按钮高亮 + 顶部红点) */
|
||
const isRecording = computed(() => meetingStore.recording.status === MEETING_RECORDING_STATUS_RECORDING)
|
||
/** 录制按钮 loading:REST 调用期间或后端异步上传期间均禁用 */
|
||
const recordingLoading = computed(() => meetingStore.recording.pending || meetingStore.recording.status === MEETING_RECORDING_STATUS_UPLOADING)
|
||
/** 录制中提示文案(VideoGrid 上方红点 hover/aria) */
|
||
const recordingHintText = computed(() => {
|
||
const r = meetingStore.recording
|
||
if (!r.status) return ''
|
||
return MEETING_RECORDING_STATUS_LABEL[r.status] || r.status
|
||
})
|
||
|
||
/** 切换录制:仅 host;按钮 emitIf 已防抖,这里再做 try/catch + toast */
|
||
const onRecordingToggle = async () => {
|
||
if (!isHost.value) return
|
||
if (recordingLoading.value) return
|
||
try {
|
||
if (isRecording.value) {
|
||
await meetingStore.stopRecording()
|
||
uni.showToast({ title: '录制已停止,正在上传…', icon: 'none' })
|
||
} else {
|
||
await meetingStore.startRecording()
|
||
uni.showToast({ title: '已开始录制', icon: 'success' })
|
||
}
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '录制操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
/** 监听录制终态:ready → 提示已就绪;failed → 错误提示 */
|
||
watch(() => meetingStore.recording.status, (newStatus, oldStatus) => {
|
||
if (newStatus === oldStatus) return
|
||
if (newStatus === MEETING_RECORDING_STATUS_READY) {
|
||
uni.showToast({ title: '录制已就绪', icon: 'success' })
|
||
} else if (newStatus === MEETING_RECORDING_STATUS_FAILED) {
|
||
const reason = meetingStore.recording.failureReason || '未知原因'
|
||
uni.showToast({ title: `录制失败:${reason}`, icon: 'none', duration: 3000 })
|
||
}
|
||
})
|
||
|
||
const openInvite = () => { inviteVisible.value = !inviteVisible.value }
|
||
const openMembers = () => { memberVisible.value = !memberVisible.value }
|
||
const openChat = () => {
|
||
// 点击工具栏聊天按钮时进行切换:已打开 → 关闭;未打开 → 打开并标记已读
|
||
if (chatVisible.value) {
|
||
chatVisible.value = false
|
||
return
|
||
}
|
||
chatVisible.value = true
|
||
meetingStore.markChatsRead()
|
||
}
|
||
const closeChat = () => { chatVisible.value = false }
|
||
|
||
watch(unreadChatCount, (count) => {
|
||
if (count > 0 && chatVisible.value) {
|
||
meetingStore.markChatsRead()
|
||
}
|
||
})
|
||
|
||
const onChatSend = async (content) => {
|
||
try {
|
||
await meetingStore.sendChat(content)
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '发送失败', icon: 'none' })
|
||
throw err
|
||
}
|
||
}
|
||
|
||
const onChatLoadMore = async () => {
|
||
if (chatLoadingMore.value) return
|
||
chatLoadingMore.value = true
|
||
try {
|
||
await meetingStore.loadMoreChats(30)
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '加载失败', icon: 'none' })
|
||
} finally {
|
||
chatLoadingMore.value = false
|
||
}
|
||
}
|
||
|
||
const onChatRequestInitialLoad = async () => {
|
||
if (meetingStore.chatHistoryLoaded) return
|
||
await onChatLoadMore()
|
||
}
|
||
|
||
const onKickMember = async (uid) => {
|
||
const confirmed = await new Promise(resolve => {
|
||
uni.showModal({
|
||
title: '踢出成员',
|
||
content: `确认将 ${nameOf(uid)} 移出会议?`,
|
||
success: (r) => resolve(r.confirm),
|
||
fail: () => resolve(false)
|
||
})
|
||
})
|
||
if (!confirmed) return
|
||
try {
|
||
await meetingStore.kickMember(uid)
|
||
uni.showToast({ title: '已踢出', icon: 'success' })
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const onMuteMember = async ({ userId: uid, mute }) => {
|
||
try {
|
||
await meetingStore.muteMember(uid, mute)
|
||
uni.showToast({ title: mute ? '已请对方静音' : '已请对方开麦', icon: 'none' })
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const onTransferHost = async (uid) => {
|
||
const confirmed = await new Promise(resolve => {
|
||
uni.showModal({
|
||
title: '转让主持人',
|
||
content: `确认将主持人身份转让给 ${nameOf(uid)}?`,
|
||
success: (r) => resolve(r.confirm),
|
||
fail: () => resolve(false)
|
||
})
|
||
})
|
||
if (!confirmed) return
|
||
try {
|
||
await meetingStore.transferHost(uid)
|
||
uni.showToast({ title: '已转让', icon: 'success' })
|
||
} catch (err) {
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const onCloudLawBack = () => {
|
||
if (minimizingToMiniProgram.value) return
|
||
minimizingToMiniProgram.value = true
|
||
meetingStore.leave().catch(() => {})
|
||
minimizeCloudLawMeeting(cloudLawCallId.value)
|
||
}
|
||
|
||
const onLeaveClick = () => {
|
||
if (showCloudLawBack.value) {
|
||
onCloudLawBack()
|
||
return
|
||
}
|
||
leaveDialogVisible.value = true
|
||
}
|
||
|
||
const confirmLeaveSelf = async () => {
|
||
leaveDialogVisible.value = false
|
||
let failed = false
|
||
try {
|
||
await meetingStore.leave()
|
||
} catch (err) {
|
||
failed = true
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
} finally {
|
||
leaveAndExit(failed ? '' : '已离开会议')
|
||
}
|
||
}
|
||
|
||
const confirmEndOrLeave = async () => {
|
||
leaveDialogVisible.value = false
|
||
const tip = isHost.value ? '会议已结束' : '已离开会议'
|
||
let failed = false
|
||
try {
|
||
if (isHost.value) {
|
||
await meetingStore.endMeeting()
|
||
// room.ended WS 广播会触发 store 内 _onRoomEnded 清理 state;保险起见再 leave 一次容错
|
||
await meetingStore.leave().catch(() => {})
|
||
} else {
|
||
await meetingStore.leave()
|
||
}
|
||
} catch (err) {
|
||
failed = true
|
||
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
|
||
} finally {
|
||
leaveAndExit(failed ? '' : tip)
|
||
}
|
||
}
|
||
|
||
// 退出会议视频界面:先给提示,再退出。
|
||
// 云律律师端「返回」→ 最小化并弹出「返回会议」;真正挂断/结束 → cloudLawCallEnded。
|
||
const leaveAndExit = (tip, ended = true) => {
|
||
if (tip) {
|
||
uni.showToast({ title: tip, icon: 'none', duration: 1200 })
|
||
}
|
||
setTimeout(() => redirectHome(ended), tip ? 600 : 800)
|
||
}
|
||
|
||
let meetingExited = false
|
||
const redirectHome = (ended = true) => {
|
||
if (meetingExited) return
|
||
if (isCloudLawEmbed() && isCloudLawLawRole(cloudLawRole.value)) {
|
||
meetingExited = true
|
||
const minimize = minimizingToMiniProgram.value || !ended
|
||
if (minimize) {
|
||
if (minimizeCloudLawMeeting(cloudLawCallId.value)) return
|
||
} else if (notifyCloudLawCallEnded(cloudLawCallId.value)) {
|
||
return
|
||
}
|
||
meetingExited = false
|
||
}
|
||
uni.reLaunch({ url: '/pages/index/index' })
|
||
}
|
||
|
||
const syncViewportHeight = () => {
|
||
// #ifdef H5
|
||
const height = window.visualViewport?.height || window.innerHeight
|
||
if (height > 0) {
|
||
document.documentElement.style.setProperty('--meeting-vh', `${height}px`)
|
||
}
|
||
// #endif
|
||
}
|
||
|
||
let cloudLawStatusPollTimer = null
|
||
let cloudLawParticipantStopWatch = null
|
||
let cloudLawSoloCheckTimer = null
|
||
let cloudLawConsultEnding = false
|
||
const hadRemoteParticipant = ref(false)
|
||
|
||
const getMyUserId = () => userStore.userInfo?.id || userStore.userInfo?.user_id
|
||
|
||
const countRemoteActiveParticipants = () => {
|
||
const myUid = getMyUserId()
|
||
return meetingStore.activeParticipants.filter(p => String(p.user_id) !== String(myUid)).length
|
||
}
|
||
|
||
const stopCloudLawStatusPoll = () => {
|
||
if (cloudLawStatusPollTimer) {
|
||
clearInterval(cloudLawStatusPollTimer)
|
||
cloudLawStatusPollTimer = null
|
||
}
|
||
}
|
||
|
||
const handleCloudLawConsultEnded = async (tip = '用户已退出咨询') => {
|
||
if (cloudLawConsultEnding || !isCloudLawLawRoleInMeeting(cloudLawRole.value)) return
|
||
cloudLawConsultEnding = true
|
||
stopCloudLawStatusPoll()
|
||
if (cloudLawSoloCheckTimer) {
|
||
clearTimeout(cloudLawSoloCheckTimer)
|
||
cloudLawSoloCheckTimer = null
|
||
}
|
||
try {
|
||
if (cloudLawCallId.value) {
|
||
await notifyCloudLawBackendEnd(cloudLawCallId.value, 'law', cloudLawApiBase.value)
|
||
if (isCloudLawPcIframe()) {
|
||
notifyCloudLawParentCallEnded(cloudLawCallId.value)
|
||
} else if (getMiniProgram()) {
|
||
notifyCloudLawCallEnded(cloudLawCallId.value)
|
||
}
|
||
}
|
||
if (meetingStore.isInMeeting) {
|
||
await meetingStore.leave().catch(() => {})
|
||
}
|
||
uni.showToast({ title: tip, icon: 'none' })
|
||
if (!isCloudLawPcIframe() && !getMiniProgram()) {
|
||
setTimeout(() => redirectHome(true), 800)
|
||
}
|
||
} finally {
|
||
cloudLawConsultEnding = false
|
||
}
|
||
}
|
||
|
||
const pollCloudLawCallStatusOnce = async () => {
|
||
if (!isCloudLawLawRoleInMeeting(cloudLawRole.value) || !cloudLawCallId.value || cloudLawConsultEnding) return
|
||
if (meetingStore.localState === MEETING_LOCAL_STATE_ENDED || meetingStore.localState === MEETING_LOCAL_STATE_LEAVING) return
|
||
const data = await queryCloudLawCallStatus(cloudLawCallId.value, 'law', cloudLawApiBase.value)
|
||
const status = String(data?.status || data?.callStatus || '').toLowerCase()
|
||
if (['ended', 'cancelled', 'failed'].includes(status)) {
|
||
await handleCloudLawConsultEnded('咨询已结束')
|
||
}
|
||
}
|
||
|
||
const startCloudLawStatusPoll = () => {
|
||
if (!isCloudLawLawRoleInMeeting(cloudLawRole.value) || !cloudLawCallId.value) return
|
||
stopCloudLawStatusPoll()
|
||
pollCloudLawCallStatusOnce()
|
||
cloudLawStatusPollTimer = setInterval(pollCloudLawCallStatusOnce, 1000)
|
||
}
|
||
|
||
const scheduleCloudLawSoloCheck = () => {
|
||
if (cloudLawSoloCheckTimer) {
|
||
clearTimeout(cloudLawSoloCheckTimer)
|
||
}
|
||
cloudLawSoloCheckTimer = setTimeout(async () => {
|
||
cloudLawSoloCheckTimer = null
|
||
if (!hadRemoteParticipant.value || cloudLawConsultEnding) return
|
||
if (meetingStore.localState !== MEETING_LOCAL_STATE_CONNECTED) return
|
||
if (countRemoteActiveParticipants() === 0) {
|
||
await handleCloudLawConsultEnded('用户已退出咨询')
|
||
}
|
||
}, 500)
|
||
}
|
||
|
||
// ============ 生命周期 ============
|
||
|
||
let stateStopWatch = null
|
||
let popstateHandler = null
|
||
|
||
// P2-3 修复:redirectTo 后必须 return,避免 onMounted 继续执行闪默认 UI
|
||
// 使用 module-scoped 标记,onMounted 读取后判断是否提前退出
|
||
let redirectingToJoin = false
|
||
|
||
onLoad((query) => {
|
||
cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
|
||
cloudLawCallId.value = query?.callId || query?.call_id || ''
|
||
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
|
||
if (!cloudLawRole.value) {
|
||
// #ifdef H5
|
||
try {
|
||
cloudLawRole.value = window.sessionStorage.getItem('echo_cloud_law_meeting_role') || ''
|
||
} catch (e) {}
|
||
// #endif
|
||
}
|
||
// 刷新页面 / 直链访问 room 时 store 为空,回跳 join 避免白屏
|
||
if (!meetingStore.isInMeeting) {
|
||
const paramCode = (query?.code || '').replace(/\D/g, '')
|
||
redirectingToJoin = true
|
||
uni.redirectTo({ url: `/pages/meeting/join${paramCode ? `?code=${paramCode}` : ''}` })
|
||
return
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
// P2-3 修复:onLoad 已 redirectTo 跳转时,避免本页继续初始化定时器与 watcher
|
||
if (redirectingToJoin) return
|
||
|
||
// #ifdef H5
|
||
syncViewportHeight()
|
||
const viewport = window.visualViewport
|
||
const onViewportChange = () => syncViewportHeight()
|
||
window.addEventListener('resize', onViewportChange)
|
||
window.addEventListener('orientationchange', onViewportChange)
|
||
viewport?.addEventListener?.('resize', onViewportChange)
|
||
viewport?.addEventListener?.('scroll', onViewportChange)
|
||
viewportCleanup = () => {
|
||
window.removeEventListener('resize', onViewportChange)
|
||
window.removeEventListener('orientationchange', onViewportChange)
|
||
viewport?.removeEventListener?.('resize', onViewportChange)
|
||
viewport?.removeEventListener?.('scroll', onViewportChange)
|
||
}
|
||
// #endif
|
||
|
||
const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
|
||
startedAt.value = startIso ? Date.parse(startIso) : Date.now()
|
||
timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
|
||
|
||
// 会议被主持人/后端结束(ENDED 状态)时,弹提示并回首页
|
||
stateStopWatch = watch(() => meetingStore.localState, (s) => {
|
||
if (s === MEETING_LOCAL_STATE_ENDED) {
|
||
if (minimizingToMiniProgram.value) {
|
||
minimizingToMiniProgram.value = false
|
||
redirectHome(false)
|
||
return
|
||
}
|
||
const reason = meetingStore.lastEndedReason
|
||
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '会议已结束'
|
||
uni.showToast({ title: label, icon: 'none' })
|
||
// 云律律师端(含 PC iframe):用户挂断后同步云律后端 + 通知父页面释放浮窗
|
||
if (isCloudLawLawRoleInMeeting(cloudLawRole.value) && cloudLawCallId.value) {
|
||
notifyCloudLawBackendEnd(cloudLawCallId.value, 'law', cloudLawApiBase.value)
|
||
if (isCloudLawPcIframe()) {
|
||
notifyCloudLawParentCallEnded(cloudLawCallId.value)
|
||
return
|
||
}
|
||
if (getMiniProgram()) {
|
||
notifyCloudLawCallEnded(cloudLawCallId.value)
|
||
return
|
||
}
|
||
}
|
||
setTimeout(() => redirectHome(true), 1200)
|
||
}
|
||
})
|
||
|
||
// 云律律师端:轮询通话状态 + 对方离会后自动收尾(用户挂断不一定能推到 room.ended)
|
||
if (isCloudLawLawRoleInMeeting(cloudLawRole.value)) {
|
||
startCloudLawStatusPoll()
|
||
cloudLawParticipantStopWatch = watch(
|
||
() => meetingStore.activeParticipants,
|
||
() => {
|
||
const remoteCount = countRemoteActiveParticipants()
|
||
if (remoteCount > 0) {
|
||
hadRemoteParticipant.value = true
|
||
return
|
||
}
|
||
if (hadRemoteParticipant.value && remoteCount === 0) {
|
||
scheduleCloudLawSoloCheck()
|
||
}
|
||
},
|
||
{ deep: true }
|
||
)
|
||
}
|
||
|
||
// #ifdef H5
|
||
if (showCloudLawBack.value) {
|
||
try {
|
||
history.pushState({ cloudLawMeeting: 1 }, '')
|
||
popstateHandler = () => {
|
||
if (!minimizingToMiniProgram.value) {
|
||
onCloudLawBack()
|
||
}
|
||
}
|
||
window.addEventListener('popstate', popstateHandler)
|
||
} catch (e) {}
|
||
}
|
||
// #endif
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (timerHandle) {
|
||
clearInterval(timerHandle)
|
||
timerHandle = null
|
||
}
|
||
if (viewportCleanup) { viewportCleanup(); viewportCleanup = null }
|
||
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
|
||
stopCloudLawStatusPoll()
|
||
if (cloudLawParticipantStopWatch) {
|
||
cloudLawParticipantStopWatch()
|
||
cloudLawParticipantStopWatch = null
|
||
}
|
||
if (cloudLawSoloCheckTimer) {
|
||
clearTimeout(cloudLawSoloCheckTimer)
|
||
cloudLawSoloCheckTimer = null
|
||
}
|
||
// #ifdef H5
|
||
if (popstateHandler) {
|
||
window.removeEventListener('popstate', popstateHandler)
|
||
popstateHandler = null
|
||
}
|
||
// #endif
|
||
})
|
||
|
||
onUnload(() => {
|
||
// 物理返回 / reLaunch 时兜底清理,避免 store 残留脏状态
|
||
if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
|
||
meetingStore.leave().catch(() => {})
|
||
}
|
||
// Task 16 资源清理:若停留在"会议已结束"页面后物理返回,清空 pinia state
|
||
// 避免 currentRoom / participants / chatMessages 等大对象常驻内存
|
||
if (meetingStore.localState === MEETING_LOCAL_STATE_ENDED) {
|
||
meetingStore.exitEndedRoom()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.room {
|
||
/* 使用 fixed + 100vw/100vh 双重保险:
|
||
某些祖先(uni-page-wrapper)带 transform 时 fixed 会相对该祖先而非视口;
|
||
显式 100vw/100vh 可保证始终铺满整个浏览器窗口。 */
|
||
position: fixed;
|
||
inset: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
height: 100svh;
|
||
height: 100dvh;
|
||
height: var(--meeting-vh, 100dvh);
|
||
min-width: 100vw;
|
||
min-height: 100vh;
|
||
min-height: 100svh;
|
||
min-height: 100dvh;
|
||
min-height: var(--meeting-vh, 100dvh);
|
||
max-height: 100vh;
|
||
max-height: 100svh;
|
||
max-height: 100dvh;
|
||
max-height: var(--meeting-vh, 100dvh);
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #0F172A;
|
||
color: #F3F4F6;
|
||
/* 必须高于 CustomTabBar(z-index:999),否则从带 tabBar 的页面
|
||
navigateTo 进会议室后,上一页遗留的 tabBar 会悬浮覆盖 toolbar
|
||
导致静音/摄像头/邀请/成员/聊天按钮全部被拦截 */
|
||
z-index: 1000;
|
||
}
|
||
|
||
.cloud-law-back {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
margin-right: 12rpx;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.cloud-law-back-icon {
|
||
font-size: 44rpx;
|
||
line-height: 1;
|
||
color: #FFFFFF;
|
||
font-weight: 300;
|
||
}
|
||
.top-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: calc(16rpx + env(safe-area-inset-top)) 24rpx 16rpx;
|
||
gap: 16rpx;
|
||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0));
|
||
position: relative;
|
||
z-index: 5;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.top-left {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.top-left-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6rpx;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.meeting-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #F9FAFB;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.code-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 4rpx 14rpx;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
border-radius: 999rpx;
|
||
color: #F3F4F6;
|
||
font-size: 22rpx;
|
||
cursor: pointer;
|
||
transition: background-color 0.15s ease;
|
||
width: fit-content;
|
||
}
|
||
.code-pill:hover { background: rgba(255, 255, 255, 0.2); }
|
||
.code-text {
|
||
font-family: 'SF Mono', 'Menlo', monospace;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
.top-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
flex-shrink: 0;
|
||
}
|
||
.state-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
padding: 4rpx 12rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 20rpx;
|
||
background: rgba(16, 185, 129, 0.2);
|
||
color: #86EFAC;
|
||
}
|
||
.state-pill.state-reconnecting {
|
||
background: rgba(245, 158, 11, 0.25);
|
||
color: #FBBF24;
|
||
}
|
||
.state-pill.state-connecting { background: rgba(59, 130, 246, 0.2); color: #93C5FD; }
|
||
.state-pill.state-leaving, .state-pill.state-ended {
|
||
background: rgba(107, 114, 128, 0.3);
|
||
color: #D1D5DB;
|
||
}
|
||
.state-dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
box-shadow: 0 0 8rpx currentColor;
|
||
}
|
||
.state-pill.state-reconnecting .state-dot,
|
||
.state-pill.state-connecting .state-dot {
|
||
animation: pulse 1.2s infinite ease-in-out;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.35; }
|
||
}
|
||
.timer {
|
||
font-size: 22rpx;
|
||
font-family: 'SF Mono', 'Menlo', monospace;
|
||
color: rgba(255, 255, 255, 0.75);
|
||
letter-spacing: 1rpx;
|
||
}
|
||
|
||
/* Phase B 录制状态指示器(顶栏) */
|
||
.recording-indicator {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6rpx;
|
||
padding: 4rpx 12rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(239, 68, 68, 0.18);
|
||
border: 1rpx solid rgba(239, 68, 68, 0.55);
|
||
color: #FCA5A5;
|
||
font-size: 20rpx;
|
||
font-weight: 600;
|
||
letter-spacing: 1rpx;
|
||
}
|
||
.recording-indicator.uploading {
|
||
background: rgba(245, 158, 11, 0.18);
|
||
border-color: rgba(245, 158, 11, 0.55);
|
||
color: #FCD34D;
|
||
}
|
||
.rec-dot {
|
||
width: 12rpx;
|
||
height: 12rpx;
|
||
border-radius: 50%;
|
||
background: #EF4444;
|
||
animation: rec-blink 1s infinite ease-in-out;
|
||
}
|
||
.recording-indicator.uploading .rec-dot {
|
||
background: #F59E0B;
|
||
}
|
||
@keyframes rec-blink {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.4; transform: scale(0.7); }
|
||
}
|
||
|
||
.body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
background: #0B1220;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: row;
|
||
}
|
||
.video-col {
|
||
flex: 1;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Phase 3 屏幕共享专属布局:大画面 + 底部横向条带 */
|
||
.video-col.has-screen { flex-direction: column; }
|
||
.screen-stage {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
padding: 12rpx;
|
||
}
|
||
.screen-stage-tile {
|
||
flex: 1;
|
||
min-height: 0;
|
||
background: #000;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
/* 屏幕画面用 contain 而非 cover,避免桌面边缘内容被裁切 */
|
||
}
|
||
.screen-stage-tile :deep(video) {
|
||
object-fit: contain !important;
|
||
background: #000;
|
||
}
|
||
.screen-strip {
|
||
flex-shrink: 0;
|
||
height: 160rpx;
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 12rpx;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
.screen-strip .strip-cell {
|
||
flex: 0 0 220rpx;
|
||
height: 100%;
|
||
border-radius: 12rpx;
|
||
overflow: hidden;
|
||
background: #111827;
|
||
}
|
||
|
||
.chat-col {
|
||
width: 0;
|
||
min-height: 0;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
transition: width 0.24s ease;
|
||
border-left: 1rpx solid rgba(255, 255, 255, 0.06);
|
||
background: #1F2937;
|
||
}
|
||
.chat-col.open { width: 640rpx; }
|
||
|
||
@media (max-width: 750px) {
|
||
.body {
|
||
padding-bottom: calc(142rpx + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
}
|
||
/* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */
|
||
.chat-col { position: fixed; top: 0; right: 0; bottom: 0; z-index: 205; }
|
||
.chat-col.open { width: 100vw; }
|
||
}
|
||
|
||
/* 离会弹窗 */
|
||
.leave-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 220;
|
||
padding: 32rpx;
|
||
}
|
||
.leave-modal {
|
||
width: 100%;
|
||
max-width: 560rpx;
|
||
background: #FFFFFF;
|
||
color: #111827;
|
||
border-radius: 24rpx;
|
||
padding: 36rpx 32rpx 28rpx;
|
||
box-shadow: 0 24rpx 48rpx rgba(0, 0, 0, 0.35);
|
||
}
|
||
.leave-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
.leave-desc {
|
||
font-size: 26rpx;
|
||
color: #4B5563;
|
||
line-height: 1.6;
|
||
margin-bottom: 32rpx;
|
||
}
|
||
.leave-actions {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
.leave-btn {
|
||
all: unset;
|
||
cursor: pointer;
|
||
padding: 18rpx 32rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 26rpx;
|
||
font-weight: 600;
|
||
transition: background-color 0.12s ease;
|
||
position: relative;
|
||
}
|
||
/* 同样抹除 uni-app H5 自动注入的 ::after 遮罩(inset: 0 -1200px -80px 0),
|
||
否则 cancel 按钮的 ::after 会覆盖到右侧确认按钮区域,
|
||
导致点击 "取消" 命中 "结束会议" 逻辑。 */
|
||
.leave-btn::after {
|
||
content: none !important;
|
||
display: none !important;
|
||
}
|
||
.leave-cancel {
|
||
background: #F3F4F6;
|
||
color: #374151;
|
||
}
|
||
.leave-cancel:hover { background: #E5E7EB; }
|
||
.leave-leave {
|
||
background: #DBEAFE;
|
||
color: #1D4ED8;
|
||
}
|
||
.leave-leave:hover { background: #BFDBFE; }
|
||
.leave-danger {
|
||
background: #EF4444;
|
||
color: #FFFFFF;
|
||
}
|
||
.leave-danger:hover { background: #DC2626; }
|
||
</style>
|