feat(phase2e-2): 会议室主页与核心 UI 组件落地(Task 11)
新增页面: - pages/meeting/room.vue:会议主容器,组装 VideoGrid/Toolbar/MemberPanel/InviteDialog * 顶部条:标题/会议号/复制、连接状态 pill、网络徽标、计时器 * 主区:按成员数自适应网格渲染 VideoTile * 底部:麦/摄/邀请/成员/聊天/挂断工具条 * 离会确认弹窗:主持人可选"结束会议 / 仅自己离开" * onUnload 兜底调用 leave 避免脏状态残留 新增组件(components/meeting/): - VideoTile.vue:原生 video/audio 挂载(H5)+ 名字/静音/主持人/说话者边框 - VideoGrid.vue:1/2/3-4/5-9/10+ 响应式 CSS Grid 布局 - MeetingToolbar.vue:6 按钮工具条,loading 态 + 徽标 - MemberPanel.vue:右侧滑入抽屉,主持人可转让/踢出 - InviteDialog.vue:居中弹窗,一键复制会议号/链接/邀请文案 - NetworkBadge.vue:4 格信号柱,level 0-4 分色 配套改动: - preview.vue:入会成功后跳 /pages/meeting/room?code=xxx(原先指向 debug) - pages.json:注册 room 路由,navigationStyle=custom 隐藏默认导航 - store/meeting.js:remoteConsumers 外层 slot 解除 markRaw,使 slot.audio/slot.video 赋值能驱动 VideoTile 响应式重渲染 验证:Playwright E2E 完成 create → preview → room 全链路 - 视频块、会议号、连接状态、计时器、工具条渲染正确 - 邀请弹窗、成员抽屉、静音切换、离会确认均工作正常 Made-with: Cursor
This commit is contained in:
319
frontend/src/components/meeting/InviteDialog.vue
Normal file
319
frontend/src/components/meeting/InviteDialog.vue
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<!--
|
||||||
|
邀请弹窗(Task 11)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 展示会议号(XXX-XXX-XXX 格式)、密码、加入链接
|
||||||
|
- 提供三个一键复制入口:复制会议号、复制邀请文案、复制加入链接
|
||||||
|
|
||||||
|
复制方案:H5 下使用 navigator.clipboard,失败时回落到 execCommand('copy')
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view v-if="visible" class="modal-root" @click.self="close">
|
||||||
|
<view class="modal" :class="{ show: show }">
|
||||||
|
<view class="head">
|
||||||
|
<text class="title">邀请加入会议</text>
|
||||||
|
<view class="btn-close" @click="close">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="body">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">会议主题</text>
|
||||||
|
<text class="value">{{ meetingTitle || '—' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row code-row">
|
||||||
|
<text class="label">会议号</text>
|
||||||
|
<view class="code-block">
|
||||||
|
<text class="code-text">{{ formattedCode }}</text>
|
||||||
|
<button class="btn-copy" @click="copy(roomCode, '会议号')">复制</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="password" class="row">
|
||||||
|
<text class="label">会议密码</text>
|
||||||
|
<view class="code-block">
|
||||||
|
<text class="code-text">{{ password }}</text>
|
||||||
|
<button class="btn-copy" @click="copy(password, '密码')">复制</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">加入链接</text>
|
||||||
|
<view class="link-block">
|
||||||
|
<text class="link-text">{{ joinLink }}</text>
|
||||||
|
<button class="btn-copy" @click="copy(joinLink, '链接')">复制</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="invite-area">
|
||||||
|
<view class="invite-box">{{ inviteText }}</view>
|
||||||
|
<button class="btn-primary" @click="copy(inviteText, '邀请信息')">一键复制邀请信息</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="toast" class="toast">{{ toast }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
roomCode: { type: String, default: '' },
|
||||||
|
password: { type: String, default: '' },
|
||||||
|
meetingTitle: { type: String, default: '' },
|
||||||
|
joinLink: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const toast = ref('')
|
||||||
|
|
||||||
|
watch(() => props.visible, (v) => {
|
||||||
|
if (v) {
|
||||||
|
setTimeout(() => { show.value = true }, 10)
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
toast.value = ''
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const formattedCode = computed(() => {
|
||||||
|
const digits = String(props.roomCode || '').replace(/\D/g, '')
|
||||||
|
if (digits.length !== 9) return digits
|
||||||
|
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const inviteText = computed(() => {
|
||||||
|
const lines = [
|
||||||
|
`邀请你参加 EchoChat 会议`,
|
||||||
|
`主题:${props.meetingTitle || '会议'}`,
|
||||||
|
`会议号:${formattedCode.value}`
|
||||||
|
]
|
||||||
|
if (props.password) lines.push(`密码:${props.password}`)
|
||||||
|
lines.push(`点击加入:${props.joinLink}`)
|
||||||
|
return lines.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
const showToast = (msg) => {
|
||||||
|
toast.value = msg
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.value === msg) toast.value = ''
|
||||||
|
}, 1600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一复制工具:优先 Clipboard API,失败降级到 textarea + execCommand */
|
||||||
|
const copy = async (text, label) => {
|
||||||
|
const data = String(text ?? '')
|
||||||
|
if (!data) return
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(data)
|
||||||
|
showToast(`${label}已复制`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = data
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.left = '-9999px'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
showToast(`${label}已复制`)
|
||||||
|
} catch {
|
||||||
|
showToast('复制失败,请手动选择')
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
uni.setClipboardData({
|
||||||
|
data,
|
||||||
|
success: () => showToast(`${label}已复制`),
|
||||||
|
fail: () => showToast('复制失败')
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => emit('close')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 210;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640rpx;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(16rpx) scale(0.96);
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 24rpx 48rpx rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.modal.show { transform: translateY(0) scale(1); opacity: 1; }
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid #F3F4F6;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
.btn-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #6B7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-close:hover { background: #F3F4F6; }
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 24rpx 32rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6B7280;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block, .link-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
background: #F9FAFB;
|
||||||
|
border: 1rpx solid #E5E7EB;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
padding: 14rpx 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #2563EB;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
background: #EFF6FF;
|
||||||
|
color: #2563EB;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.btn-copy:hover { background: #DBEAFE; }
|
||||||
|
.btn-copy:active { background: #BFDBFE; }
|
||||||
|
|
||||||
|
.invite-area {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.invite-box {
|
||||||
|
background: #F3F4F6;
|
||||||
|
color: #374151;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 300rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
background: #3B82F6;
|
||||||
|
color: #FFFFFF;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #2563EB; }
|
||||||
|
.btn-primary:active { background: #1D4ED8; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 140rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(17, 24, 39, 0.92);
|
||||||
|
color: #FFFFFF;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: toast-in 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translate(-50%, 10rpx); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
frontend/src/components/meeting/MeetingToolbar.vue
Normal file
193
frontend/src/components/meeting/MeetingToolbar.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!--
|
||||||
|
会议工具条(Task 11)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 渲染麦/摄/邀请/成员/聊天/挂断 6 个主要操作按钮
|
||||||
|
- 通过事件派发意图,不持有业务状态(状态由父组件 / Pinia 管理)
|
||||||
|
|
||||||
|
交互:
|
||||||
|
- 麦克风 / 摄像头按钮显示当前状态(开 = 蓝色,关 = 深灰)
|
||||||
|
- 挂断按钮为红色,点击仅派发事件,确认弹窗由父页面承接(降低本组件耦合)
|
||||||
|
- 按钮带 loading 态,禁用期间点击不派发事件
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="toolbar">
|
||||||
|
<button class="btn" :class="{ active: audioEnabled, loading: audioLoading }" :disabled="audioLoading" @click="emitIf('mic-toggle')">
|
||||||
|
<view class="icon">
|
||||||
|
<svg v-if="audioEnabled" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
|
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||||
|
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
<text class="label">{{ audioEnabled ? '静音' : '解除' }}</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn" :class="{ active: videoEnabled, loading: videoLoading }" :disabled="videoLoading" @click="emitIf('cam-toggle')">
|
||||||
|
<view class="icon">
|
||||||
|
<svg v-if="videoEnabled" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
|
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v4m0 0l6-4v10"></path>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
<text class="label">{{ videoEnabled ? '停止视频' : '开启视频' }}</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn" @click="emitIf('invite')">
|
||||||
|
<view class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="8.5" cy="7" r="4"></circle>
|
||||||
|
<line x1="20" y1="8" x2="20" y2="14"></line>
|
||||||
|
<line x1="23" y1="11" x2="17" y2="11"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
<text class="label">邀请</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn" @click="emitIf('members')">
|
||||||
|
<view class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
<view v-if="memberCount > 0" class="badge">{{ memberCount > 99 ? '99+' : memberCount }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="label">成员</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn" :disabled="!allowChat" @click="emitIf('chat')">
|
||||||
|
<view class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<view v-if="unreadChatCount > 0" class="badge badge-red">{{ unreadChatCount > 99 ? '99+' : unreadChatCount }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="label">聊天</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-leave" @click="emitIf('leave')">
|
||||||
|
<view class="icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"></path>
|
||||||
|
<line x1="23" y1="1" x2="1" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
<text class="label">离开</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
audioEnabled: { type: Boolean, default: false },
|
||||||
|
videoEnabled: { type: Boolean, default: false },
|
||||||
|
audioLoading: { type: Boolean, default: false },
|
||||||
|
videoLoading: { type: Boolean, default: false },
|
||||||
|
memberCount: { type: Number, default: 0 },
|
||||||
|
unreadChatCount: { type: Number, default: 0 },
|
||||||
|
allowChat: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['mic-toggle', 'cam-toggle', 'invite', 'members', 'chat', 'leave'])
|
||||||
|
|
||||||
|
/** 统一过滤 loading 态,避免重复点击 */
|
||||||
|
const emitIf = (name) => {
|
||||||
|
if (name === 'mic-toggle' && props.audioLoading) return
|
||||||
|
if (name === 'cam-toggle' && props.videoLoading) return
|
||||||
|
emit(name)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom)) 24rpx;
|
||||||
|
background: rgba(17, 24, 39, 0.88);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 104rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 8rpx 8rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
.btn:hover { background: rgba(255, 255, 255, 0.06); }
|
||||||
|
.btn:active { transform: translateY(1rpx); }
|
||||||
|
.btn[disabled] { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(55, 65, 81, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #FFFFFF;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn.active .icon { background: #3B82F6; }
|
||||||
|
.btn.loading .icon { opacity: 0.6; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-leave .icon { background: #EF4444; }
|
||||||
|
.btn-leave:hover .icon { background: #DC2626; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6rpx;
|
||||||
|
right: -6rpx;
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
padding: 0 6rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #10B981;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 18rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.badge.badge-red { background: #EF4444; }
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.btn { min-width: 88rpx; }
|
||||||
|
.icon { width: 64rpx; height: 64rpx; }
|
||||||
|
.label { font-size: 20rpx; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
336
frontend/src/components/meeting/MemberPanel.vue
Normal file
336
frontend/src/components/meeting/MemberPanel.vue
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
<!--
|
||||||
|
成员抽屉(Task 11)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 展示当前会议参与者列表(排序:主持人置顶 → 自己 → 其他加入时间倒序)
|
||||||
|
- 主持人可通过行尾菜单 转让主持 / 踢出成员;事件派发由父组件承接 API 调用
|
||||||
|
- 受控组件:通过 visible + @close 控制显隐,遮罩点击关闭
|
||||||
|
|
||||||
|
说明:参与者基础字段来自 meeting.member.joined 广播,name/nickname 字段
|
||||||
|
由后端后续补全;当前 fallback 到 "用户 {user_id}"
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view v-if="visible" class="panel-root" @click.self="onMaskClick">
|
||||||
|
<view class="panel" :class="{ show: show }">
|
||||||
|
<view class="header">
|
||||||
|
<view class="title-area">
|
||||||
|
<text class="title">会议成员</text>
|
||||||
|
<text class="count">({{ participants.length }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="btn-close" @click="close">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y class="list">
|
||||||
|
<view
|
||||||
|
v-for="p in sortedList"
|
||||||
|
:key="p.user_id"
|
||||||
|
class="row"
|
||||||
|
>
|
||||||
|
<view class="avatar" :style="avatarStyle(p.user_id)">
|
||||||
|
{{ nameInitial(p) }}
|
||||||
|
</view>
|
||||||
|
<view class="info">
|
||||||
|
<view class="info-name">
|
||||||
|
<text class="name-text">{{ displayName(p) }}</text>
|
||||||
|
<view v-if="p.user_id === currentUserId" class="tag tag-self">你</view>
|
||||||
|
<view v-if="p.user_id === hostId" class="tag tag-host">主持人</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-state">
|
||||||
|
<view class="state-icon" :class="{ off: !p.audio_enabled }">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
<view class="state-icon" :class="{ off: !p.video_enabled }">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="canOperate(p)"
|
||||||
|
class="actions"
|
||||||
|
@click.stop="toggleMenu(p.user_id)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="1"></circle>
|
||||||
|
<circle cx="12" cy="5" r="1"></circle>
|
||||||
|
<circle cx="12" cy="19" r="1"></circle>
|
||||||
|
</svg>
|
||||||
|
<view v-if="openMenuUid === p.user_id" class="menu" @click.stop>
|
||||||
|
<view class="menu-item" @click="onTransfer(p)">
|
||||||
|
<text>转让主持人</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-item menu-danger" @click="onKick(p)">
|
||||||
|
<text>踢出会议</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!sortedList.length" class="empty-state">
|
||||||
|
<text>当前会议室还没有其他成员</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
participants: { type: Array, default: () => [] },
|
||||||
|
currentUserId: { type: [Number, String], default: 0 },
|
||||||
|
hostId: { type: [Number, String], default: 0 },
|
||||||
|
isHost: { type: Boolean, default: false },
|
||||||
|
displayNameMap: { type: Object, default: () => ({}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'kick', 'transfer-host'])
|
||||||
|
|
||||||
|
const openMenuUid = ref(0)
|
||||||
|
const show = ref(false)
|
||||||
|
|
||||||
|
/** 动画:先挂载再 nextTick 触发 transform */
|
||||||
|
watch(() => props.visible, (v) => {
|
||||||
|
if (v) {
|
||||||
|
setTimeout(() => { show.value = true }, 10)
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
openMenuUid.value = 0
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const sortedList = computed(() => {
|
||||||
|
const list = props.participants.filter(p => p.is_active !== false)
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (a.user_id === props.hostId) return -1
|
||||||
|
if (b.user_id === props.hostId) return 1
|
||||||
|
if (a.user_id === props.currentUserId) return -1
|
||||||
|
if (b.user_id === props.currentUserId) return 1
|
||||||
|
const ta = a.joined_at ? Date.parse(a.joined_at) : 0
|
||||||
|
const tb = b.joined_at ? Date.parse(b.joined_at) : 0
|
||||||
|
return ta - tb
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayName = (p) => {
|
||||||
|
if (p.nickname) return p.nickname
|
||||||
|
if (p.username) return p.username
|
||||||
|
if (props.displayNameMap[p.user_id]) return props.displayNameMap[p.user_id]
|
||||||
|
return `用户 ${p.user_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameInitial = (p) => {
|
||||||
|
const n = displayName(p).trim()
|
||||||
|
return n ? n.charAt(0).toUpperCase() : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarStyle = (uid) => {
|
||||||
|
const seed = String(uid || '0')
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360
|
||||||
|
return { background: `hsl(${h}, 60%, 48%)` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const canOperate = (p) => {
|
||||||
|
return props.isHost && p.user_id !== props.currentUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMenu = (uid) => {
|
||||||
|
openMenuUid.value = openMenuUid.value === uid ? 0 : uid
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTransfer = (p) => {
|
||||||
|
openMenuUid.value = 0
|
||||||
|
emit('transfer-host', p.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKick = (p) => {
|
||||||
|
openMenuUid.value = 0
|
||||||
|
emit('kick', p.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => emit('close')
|
||||||
|
const onMaskClick = () => close()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 620rpx;
|
||||||
|
max-width: 88vw;
|
||||||
|
height: 100%;
|
||||||
|
background: #1F2937;
|
||||||
|
color: #F3F4F6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.24s ease;
|
||||||
|
box-shadow: -4rpx 0 24rpx rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.panel.show { transform: translateX(0); }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.title-area { display: flex; align-items: baseline; gap: 10rpx; }
|
||||||
|
.title { font-size: 32rpx; font-weight: 600; }
|
||||||
|
.count { color: rgba(255, 255, 255, 0.5); font-size: 24rpx; }
|
||||||
|
.btn-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-close:hover { background: rgba(255, 255, 255, 0.08); }
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.row:hover { background: rgba(255, 255, 255, 0.04); }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
.info-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.name-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tag-self { background: rgba(59, 130, 246, 0.85); }
|
||||||
|
.tag-host { background: rgba(245, 158, 11, 0.9); }
|
||||||
|
|
||||||
|
.info-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
.state-icon {
|
||||||
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(59, 130, 246, 0.8);
|
||||||
|
color: #FFFFFF;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.state-icon.off {
|
||||||
|
background: rgba(107, 114, 128, 0.55);
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: relative;
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.actions:hover { background: rgba(255, 255, 255, 0.08); }
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 64rpx;
|
||||||
|
right: 0;
|
||||||
|
min-width: 220rpx;
|
||||||
|
background: #374151;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #F3F4F6;
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.menu-item:hover { background: rgba(255, 255, 255, 0.08); }
|
||||||
|
.menu-danger { color: #F87171; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 80rpx 32rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
frontend/src/components/meeting/NetworkBadge.vue
Normal file
75
frontend/src/components/meeting/NetworkBadge.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<!--
|
||||||
|
网络质量徽标(Task 11)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 渲染 4 格信号柱(类似手机信号图标),根据 level (0-4) 点亮
|
||||||
|
- 可选展示文字标签(默认隐藏,hover/长按再显示)
|
||||||
|
- 本组件不产生网络数据,由父页面在 Task 15 接入 mediasoup getStats 后喂入
|
||||||
|
|
||||||
|
level 语义:
|
||||||
|
- 4 excellent / 3 good / 2 fair / 1 poor / 0 disconnected
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="badge" :class="levelClass" :title="labelText">
|
||||||
|
<view v-for="n in 4" :key="n" class="bar" :class="{ on: n <= level }"
|
||||||
|
:style="{ height: `${n * 20}%` }"
|
||||||
|
></view>
|
||||||
|
<text v-if="showLabel" class="label">{{ labelText }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
level: { type: Number, default: 4 },
|
||||||
|
showLabel: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const LEVEL_LABEL = ['已断开', '很差', '一般', '良好', '优秀']
|
||||||
|
|
||||||
|
const labelText = computed(() => LEVEL_LABEL[Math.max(0, Math.min(4, props.level))])
|
||||||
|
|
||||||
|
const levelClass = computed(() => {
|
||||||
|
if (props.level >= 4) return 'lv-excellent'
|
||||||
|
if (props.level >= 3) return 'lv-good'
|
||||||
|
if (props.level >= 2) return 'lv-fair'
|
||||||
|
if (props.level >= 1) return 'lv-poor'
|
||||||
|
return 'lv-off'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 3rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
padding: 4rpx 8rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 6rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
border-radius: 2rpx;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lv-excellent .bar.on { background: #10B981; }
|
||||||
|
.lv-good .bar.on { background: #22C55E; }
|
||||||
|
.lv-fair .bar.on { background: #F59E0B; }
|
||||||
|
.lv-poor .bar.on { background: #EF4444; }
|
||||||
|
.lv-off .bar { background: rgba(255, 255, 255, 0.2); }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
frontend/src/components/meeting/VideoGrid.vue
Normal file
100
frontend/src/components/meeting/VideoGrid.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!--
|
||||||
|
会议视频网格(Task 11)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 根据成员数量自适应网格布局(1/2/3-4/5-6/7-9/10+)
|
||||||
|
- 手机端竖排为主,桌面端以 16:9 单元格填充
|
||||||
|
- 不关心业务数据,仅负责排版;视频块由 slot #tile 自定义渲染,保持数据与组件解耦
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="grid-root">
|
||||||
|
<view class="grid" :class="layoutClass" :style="gridStyle">
|
||||||
|
<view
|
||||||
|
v-for="(tile, idx) in tiles"
|
||||||
|
:key="tile.key || tile.userId || idx"
|
||||||
|
class="grid-cell"
|
||||||
|
>
|
||||||
|
<slot name="tile" :tile="tile" :index="idx" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="!tiles.length" class="empty">
|
||||||
|
<text>暂无成员,等待其他人加入…</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tiles: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 根据成员数量计算列数 */
|
||||||
|
const columns = computed(() => {
|
||||||
|
const n = props.tiles.length
|
||||||
|
if (n <= 1) return 1
|
||||||
|
if (n <= 4) return 2
|
||||||
|
if (n <= 9) return 3
|
||||||
|
if (n <= 16) return 4
|
||||||
|
return Math.ceil(Math.sqrt(n))
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
const n = props.tiles.length
|
||||||
|
if (!n) return 1
|
||||||
|
return Math.ceil(n / columns.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gridStyle = computed(() => ({
|
||||||
|
'grid-template-columns': `repeat(${columns.value}, minmax(0, 1fr))`,
|
||||||
|
'grid-template-rows': `repeat(${rows.value}, minmax(0, 1fr))`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const layoutClass = computed(() => `layout-${Math.min(props.tiles.length, 10)}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.grid-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cell {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
font-size: 26rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端单人时占满整屏 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid.layout-1 { grid-template-rows: 1fr !important; }
|
||||||
|
.grid.layout-2 {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
grid-template-rows: 1fr 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
273
frontend/src/components/meeting/VideoTile.vue
Normal file
273
frontend/src/components/meeting/VideoTile.vue
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<!--
|
||||||
|
会议视频块(Task 11)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 挂载单个成员的 video/audio MediaStreamTrack(H5 下用原生 <video>/<audio>,因 uni <video> 不支持 srcObject)
|
||||||
|
- 视频关闭时回落到头像占位
|
||||||
|
- 右下角角标:昵称 + 麦克风静音图标 + 主持人徽章
|
||||||
|
- 说话者流光边框(通过 isSpeaking prop 控制,实际探测留给上层)
|
||||||
|
|
||||||
|
平台说明:主要支持 H5 / Web 浏览器,条件编译指令保留但本组件核心能力仅在 H5 生效
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="tile" :class="{ speaking: isSpeaking, local: isLocal, compact }">
|
||||||
|
<view ref="mediaBox" class="media-slot"></view>
|
||||||
|
|
||||||
|
<view v-if="!hasVideo" class="avatar-fallback">
|
||||||
|
<view class="avatar-circle" :style="avatarStyle">{{ avatarInitial }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLocal" class="tag tag-self">你</view>
|
||||||
|
<view v-if="isHost" class="tag tag-host" title="主持人">主持人</view>
|
||||||
|
|
||||||
|
<view class="footer">
|
||||||
|
<text class="name">{{ name }}</text>
|
||||||
|
<view class="icons">
|
||||||
|
<view v-if="!audioEnabled" class="icon icon-mic-off" title="已静音">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||||
|
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||||
|
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
userId: { type: [Number, String], required: true },
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
isLocal: { type: Boolean, default: false },
|
||||||
|
isHost: { type: Boolean, default: false },
|
||||||
|
audioEnabled: { type: Boolean, default: true },
|
||||||
|
videoEnabled: { type: Boolean, default: true },
|
||||||
|
audioTrack: { type: Object, default: null },
|
||||||
|
videoTrack: { type: Object, default: null },
|
||||||
|
isSpeaking: { type: Boolean, default: false },
|
||||||
|
compact: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const mediaBox = ref(null)
|
||||||
|
|
||||||
|
const hasVideo = computed(() => !!props.videoTrack && props.videoEnabled)
|
||||||
|
|
||||||
|
const avatarInitial = computed(() => {
|
||||||
|
const s = (props.name || '').trim()
|
||||||
|
return s ? s.charAt(0).toUpperCase() : '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 基于 userId 稳定生成头像背景色(HSL 色相) */
|
||||||
|
const avatarStyle = computed(() => {
|
||||||
|
const seed = String(props.userId || '0')
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360
|
||||||
|
return { background: `hsl(${h}, 60%, 48%)` }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取或创建容器下的原生 media 元素 */
|
||||||
|
const ensureMediaEl = (tagName) => {
|
||||||
|
// #ifdef H5
|
||||||
|
const parent = resolveDom(mediaBox.value)
|
||||||
|
if (!parent) return null
|
||||||
|
let el = parent.querySelector(`:scope > ${tagName}`)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(tagName)
|
||||||
|
el.autoplay = true
|
||||||
|
el.playsInline = true
|
||||||
|
if (tagName === 'video') {
|
||||||
|
el.style.width = '100%'
|
||||||
|
el.style.height = '100%'
|
||||||
|
el.style.objectFit = 'cover'
|
||||||
|
el.style.background = '#0B1220'
|
||||||
|
}
|
||||||
|
parent.appendChild(el)
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return null
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMediaEl = (tagName) => {
|
||||||
|
// #ifdef H5
|
||||||
|
const parent = resolveDom(mediaBox.value)
|
||||||
|
if (!parent) return
|
||||||
|
const el = parent.querySelector(`:scope > ${tagName}`)
|
||||||
|
if (el) {
|
||||||
|
try { el.srcObject = null } catch {}
|
||||||
|
parent.removeChild(el)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDom = (refValue) => {
|
||||||
|
if (!refValue) return null
|
||||||
|
// #ifdef H5
|
||||||
|
if (refValue instanceof HTMLElement) return refValue
|
||||||
|
if (refValue.$el) return refValue.$el
|
||||||
|
if (refValue.exposed && refValue.exposed.$el) return refValue.exposed.$el
|
||||||
|
// #endif
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyVideo = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
if (props.videoTrack && props.videoEnabled) {
|
||||||
|
const el = ensureMediaEl('video')
|
||||||
|
if (el) {
|
||||||
|
el.muted = props.isLocal
|
||||||
|
el.srcObject = new MediaStream([props.videoTrack])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeMediaEl('video')
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyAudio = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
// 本地 tile 不挂载 audio(避免回声)
|
||||||
|
if (!props.isLocal && props.audioTrack && props.audioEnabled) {
|
||||||
|
const el = ensureMediaEl('audio')
|
||||||
|
if (el) {
|
||||||
|
el.srcObject = new MediaStream([props.audioTrack])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeMediaEl('audio')
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.videoTrack, props.videoEnabled], () => nextTick(applyVideo), { immediate: true })
|
||||||
|
watch(() => [props.audioTrack, props.audioEnabled, props.isLocal], () => nextTick(applyAudio), { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
removeMediaEl('video')
|
||||||
|
removeMediaEl('audio')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0B1220;
|
||||||
|
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.25);
|
||||||
|
transition: box-shadow 0.18s ease, transform 0.18s ease;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
}
|
||||||
|
.tile.speaking {
|
||||||
|
border-color: #3B82F6;
|
||||||
|
box-shadow: 0 0 0 2rpx rgba(59, 130, 246, 0.18), 0 0 28rpx rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
.tile.local::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
box-shadow: inset 0 0 0 2rpx rgba(59, 130, 246, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-slot {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-fallback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.avatar-circle {
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 52rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
letter-spacing: 0;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.tile.compact .avatar-circle {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
font-size: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 16rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.tag-self {
|
||||||
|
left: 16rpx;
|
||||||
|
background: rgba(59, 130, 246, 0.7);
|
||||||
|
}
|
||||||
|
.tag-host {
|
||||||
|
right: 16rpx;
|
||||||
|
background: rgba(245, 158, 11, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 14rpx 18rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8rpx;
|
||||||
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.icons { display: flex; align-items: center; gap: 8rpx; flex-shrink: 0; }
|
||||||
|
.icon {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.icon-mic-off {
|
||||||
|
background: rgba(239, 68, 68, 0.9);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.compact .footer { padding: 8rpx 12rpx; }
|
||||||
|
.tile.compact .name { font-size: 20rpx; }
|
||||||
|
.tile.compact .tag { font-size: 18rpx; padding: 2rpx 8rpx; }
|
||||||
|
</style>
|
||||||
@@ -166,6 +166,13 @@
|
|||||||
"navigationBarTitleText": "设备预览"
|
"navigationBarTitleText": "设备预览"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/meeting/room",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "会议进行中",
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/notify/index",
|
"path": "pages/notify/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -12,8 +12,7 @@
|
|||||||
- 「入会默认开麦/摄像头」两个开关写入 mediaPrefs
|
- 「入会默认开麦/摄像头」两个开关写入 mediaPrefs
|
||||||
- 兜底:权限被拒 / 浏览器不支持 WebRTC 时给明确错误提示
|
- 兜底:权限被拒 / 浏览器不支持 WebRTC 时给明确错误提示
|
||||||
|
|
||||||
入会成功后(Task 11 room 页未就绪期间):uni.redirectTo 到会议调试页 debug.vue 作为会议室占位。
|
入会成功后:uni.redirectTo('/pages/meeting/room?code=xxx')
|
||||||
TODO(Task 11 落地后):改为 uni.redirectTo '/pages/meeting/room?code=xxx'
|
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
@@ -362,8 +361,8 @@ const onJoin = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await meetingStore.joinAndEnter(joinCode.value, joinPassword.value, prefs)
|
await meetingStore.joinAndEnter(joinCode.value, joinPassword.value, prefs)
|
||||||
}
|
}
|
||||||
// Task 11 会议室页尚未落地,临时跳转 debug.vue 作为会议室占位
|
const roomCode = meetingStore.currentRoom?.room_code
|
||||||
uni.redirectTo({ url: '/pages/meeting/debug' })
|
uni.redirectTo({ url: `/pages/meeting/room${roomCode ? `?code=${roomCode}` : ''}` })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message || JSON.stringify(err)
|
const msg = err?.message || JSON.stringify(err)
|
||||||
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
uni.showToast({ title: `加入失败:${msg}`, icon: 'none', duration: 3500 })
|
||||||
|
|||||||
647
frontend/src/pages/meeting/room.vue
Normal file
647
frontend/src/pages/meeting/room.vue
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
<!--
|
||||||
|
会议主页面(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>
|
||||||
|
|
||||||
|
<!-- 视频网格 -->
|
||||||
|
<view class="body">
|
||||||
|
<VideoGrid :tiles="tiles">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VideoGrid>
|
||||||
|
</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"
|
||||||
|
@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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 邀请弹窗 -->
|
||||||
|
<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'
|
||||||
|
|
||||||
|
const meetingStore = useMeetingStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const memberVisible = ref(false)
|
||||||
|
const inviteVisible = ref(false)
|
||||||
|
const leaveDialogVisible = ref(false)
|
||||||
|
const audioLoading = ref(false)
|
||||||
|
const videoLoading = ref(false)
|
||||||
|
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
|
||||||
|
const unreadChatCount = ref(0) // Task 14 接入 chat 后动态更新
|
||||||
|
const startedAt = ref(0)
|
||||||
|
const nowTs = ref(Date.now())
|
||||||
|
let timerHandle = null
|
||||||
|
|
||||||
|
// ============ 基础映射 ============
|
||||||
|
|
||||||
|
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 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
|
||||||
|
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 : (p.audio_enabled !== false),
|
||||||
|
videoEnabled: isLocal ? videoEnabled.value : (p.video_enabled !== false),
|
||||||
|
audioTrack: null,
|
||||||
|
videoTrack: null
|
||||||
|
}
|
||||||
|
if (isLocal) {
|
||||||
|
tile.audioTrack = meetingStore.getLocalTrack('audio')
|
||||||
|
tile.videoTrack = meetingStore.getLocalTrack('video')
|
||||||
|
} else {
|
||||||
|
tile.audioTrack = meetingStore.getRemoteTrack(p.user_id, 'audio')
|
||||||
|
tile.videoTrack = meetingStore.getRemoteTrack(p.user_id, 'video')
|
||||||
|
}
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============ 事件处理 ============
|
||||||
|
|
||||||
|
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 = true }
|
||||||
|
const openMembers = () => { memberVisible.value = true }
|
||||||
|
const openChat = () => {
|
||||||
|
// Task 14 会议聊天入口;当前阶段仅提示
|
||||||
|
uni.showToast({ title: '聊天功能即将上线', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.room {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #0F172A;
|
||||||
|
color: #F3F4F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 离会弹窗 */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
@@ -306,11 +306,13 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
try {
|
try {
|
||||||
const consumer = await _engine.consume({ producerId: data.producer_id })
|
const consumer = await _engine.consume({ producerId: data.producer_id })
|
||||||
if (!remoteConsumers[data.user_id]) {
|
if (!remoteConsumers[data.user_id]) {
|
||||||
remoteConsumers[data.user_id] = markRaw({
|
// 外层 slot 保持 reactive 以便 slot.audio/slot.video 赋值能驱动 VideoTile 重渲染;
|
||||||
|
// 仅 Consumer 实例和 Set 本身用 markRaw 隔离 Vue 代理
|
||||||
|
remoteConsumers[data.user_id] = {
|
||||||
audio: null,
|
audio: null,
|
||||||
video: null,
|
video: null,
|
||||||
producerIds: new Set()
|
producerIds: markRaw(new Set())
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
const slot = remoteConsumers[data.user_id]
|
const slot = remoteConsumers[data.user_id]
|
||||||
slot[consumer.kind] = markRaw(consumer)
|
slot[consumer.kind] = markRaw(consumer)
|
||||||
|
|||||||
Reference in New Issue
Block a user