Files
EchoChat/frontend/src/pages/meeting/room.vue
2026-06-04 12:23:14 +08:00

1212 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
会议主页面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">
<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 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"
@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'
const meetingStore = useMeetingStore()
const userStore = useUserStore()
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)
/** 录制按钮 loadingREST 调用期间或后端异步上传期间均禁用 */
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 onLeaveClick = () => { 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)
}
}
// 是否运行在云律小程序 web-view 内SSO 进入时打的标记)
const isCloudLawEmbed = () => {
try {
return typeof window !== 'undefined'
&& !!window.sessionStorage
&& window.sessionStorage.getItem('echo_cloud_law_meeting_embed') === '1'
} catch (e) {
return false
}
}
const getMiniProgram = () => {
return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
}
// 退出会议视频界面:先给提示,再退出。
// 在云律小程序 web-view 内 → 通知并 navigateBack 回到小程序(关闭会议 web-view 页);
// 普通浏览器/PC → 回 EchoChat 首页。
const leaveAndExit = (tip) => {
if (tip) {
uni.showToast({ title: tip, icon: 'none', duration: 1200 })
}
// 留一点时间让提示可见,再退出视频界面
setTimeout(redirectHome, tip ? 600 : 200)
}
let meetingExited = false
const redirectHome = () => {
if (meetingExited) return
const miniProgram = getMiniProgram()
if (isCloudLawEmbed() && miniProgram) {
meetingExited = true
try {
miniProgram.postMessage({ data: { type: 'cloudLawCallEnded', action: 'cloudLawCallEnded' } })
} catch (e) {}
try {
if (typeof miniProgram.navigateBack === 'function') {
miniProgram.navigateBack({ delta: 1 })
return
}
if (typeof miniProgram.redirectTo === 'function') {
miniProgram.redirectTo({ url: '/pages/index/home_page/home_page' })
return
}
} catch (e) {
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 stateStopWatch = null
// P2-3 修复redirectTo 后必须 return避免 onMounted 继续执行闪默认 UI
// 使用 module-scoped 标记onMounted 读取后判断是否提前退出
let redirectingToJoin = false
onLoad((query) => {
// 刷新页面 / 直链访问 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) {
const reason = meetingStore.lastEndedReason
const label = MEETING_ENDED_REASON_LABEL?.[reason] || '会议已结束'
uni.showToast({ title: label, icon: 'none' })
setTimeout(redirectHome, 1200)
}
})
})
onBeforeUnmount(() => {
if (timerHandle) {
clearInterval(timerHandle)
timerHandle = null
}
if (viewportCleanup) { viewportCleanup(); viewportCleanup = null }
if (stateStopWatch) { stateStopWatch(); stateStopWatch = null }
})
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;
/* 必须高于 CustomTabBarz-index:999否则从带 tabBar 的页面
navigateTo 进会议室后,上一页遗留的 tabBar 会悬浮覆盖 toolbar
导致静音/摄像头/邀请/成员/聊天按钮全部被拦截 */
z-index: 1000;
}
.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: 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>