Files
EchoChat/frontend/src/pages/meeting/room.vue
bujinyuan f5ae095033 fix(meeting): Task 16 会议销毁路径资源清理专项
前端修复:
- _onRoomEnded 保留结束页数据,新增 exitEndedRoom() 动作
  room.vue onUnload 在 ENDED 状态调用,释放 currentRoom / participants / chatMessages 等 pinia state
- endMeeting() API 成功后立即触发 _onRoomEnded 本地兜底,不再依赖 room.ended 广播
  避免 API 成功但 WS 抖动导致 engine / timer / AudioContext 泄漏
- 新增 _pendingBroadcastTimers 集合收纳 _broadcastSelfState 的 setTimeout 句柄
  _cleanupMedia 统一 clearTimeout,避免会议结束后 stale 重试逻辑排队

后端修复:
- MeetingLifecycleService.OnRoomEnded 统一生命周期收尾
  取消 graceTimers / emptyTTLTimers + Pipeline DEL host_grace / empty_ttl / 两个 handling 锁 key
- EndRoom 事务提交后遍历 activesBefore 调 cleanupRoomRedisResidual
  批量 DEL 每个前任活跃成员的 resourceTrackKey + memberStateKey
  补调 lifecycleSvc.OnRoomEnded 清理自身 timer + keys
- HandleEmptyRoomExpired MarkEnded 后补调 cleanupRoomRedisResidual + 清 host_grace 残留

验证:go vet + go build + npm run build:h5 全部通过

Made-with: Cursor
2026-04-23 17:12:38 +08:00

829 lines
25 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>
<NetworkBadge :level="networkLevel" />
<text class="timer">{{ durationText }}</text>
</view>
</view>
<!-- 主区左视频 + 右侧聊天面板 chatVisible 时侧栏展开 -->
<view class="body">
<view ref="videoCol" class="video-col">
<VideoGrid :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"
@mic-toggle="onMicToggle"
@cam-toggle="onCamToggle"
@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
} 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 chatLoadingMore = ref(false)
const networkLevel = ref(4) // 持续接入 getStats 后动态更新Task 16 进一步收敛)
const startedAt = ref(0)
const nowTs = ref(Date.now())
let timerHandle = 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)
/** 监听 store.getLocalTrack 的变化localProducers 变化即 bump 版本 */
const localTrackVersion = computed(() => {
return `${meetingStore.localProducers.audio || ''}-${meetingStore.localProducers.video || ''}`
})
/** 远端 track 版本:让 tiles 在 slot.audio/slot.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('|')
})
/** 参与者姓名缓存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
}
}
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
try {
await meetingStore.leave()
} finally {
redirectHome()
}
}
const confirmEndOrLeave = async () => {
leaveDialogVisible.value = 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) {
uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
} finally {
redirectHome()
}
}
const redirectHome = () => {
uni.reLaunch({ url: '/pages/index/index' })
}
// ============ 生命周期 ============
let stateStopWatch = null
onLoad((query) => {
// 刷新页面 / 直链访问 room 时 store 为空,回跳 join 避免白屏
if (!meetingStore.isInMeeting) {
const paramCode = (query?.code || '').replace(/\D/g, '')
uni.redirectTo({ url: `/pages/meeting/join${paramCode ? `?code=${paramCode}` : ''}` })
}
})
onMounted(() => {
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 (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;
min-width: 100vw;
min-height: 100vh;
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;
}
.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;
}
.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) {
/* 小屏幕:聊天面板改为全屏浮层,避免挤压视频 */
.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>