Files
tauri-meeting/src/views/meeting/meeting-room/meeting-room-monitor.vue
O昵称重要吗O bd31d23bda feat(meeting): 新增学生端和监控端会议页面
- 在路由中新增学生端和监控端页面路由
- 修改加入会议逻辑,根据角色跳转到不同页面
- 新增监控端页面,支持多学生画面宫格布局
- 新增学生端页面,支持主画面显示和本地视频小窗拖拽
- 扩展 useAgoraMeeting hook 支持禁用本地音视频轨道
- 更新悬浮列表窗口的会议和字幕创建函数参数
- 添加课程和评讲信息的存储常量
2026-03-13 13:08:30 +08:00

420 lines
11 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.

<template>
<div class="page">
<div class="page-content">
<div class="top-bar">
<div class="room-info">
<icon-mdi-video class="room-icon"></icon-mdi-video>
<div class="room-details">
<span class="room-name">{{ courseRoomName }}</span>
<span class="room-id">监控端 · 房间 ID: {{ courseRoomId }}</span>
</div>
</div>
<div class="network-status" :class="networkQualityClass">
<icon-mdi-wifi class="network-icon"></icon-mdi-wifi>
<span class="network-text">{{ networkStatusText }}</span>
</div>
</div>
<div class="monitor-container">
<el-empty v-if="classStatus !== 'in_class'" :description="classStatus === 'not_started' ? '等待老师上课...' : '老师已下课,等待重新上课...'"></el-empty>
<div v-else-if="studentUsers.length === 0" class="empty-students">
<el-empty description="暂无学生画面"></el-empty>
</div>
<div
v-else
class="grid"
:style="{
'--grid-cols': String(gridCols),
'--grid-rows': String(gridRows),
}"
>
<div v-for="u in studentUsers" :key="String(u.uid)" class="grid-item">
<div :id="`player-${u.uid}`" class="player-wrapper"></div>
<div class="video-label">
<span>{{ u.userName || `用户 ${u.uid}` }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
import IconMdiWifi from '~icons/mdi/wifi';
import IconMdiVideo from '~icons/mdi/video';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage as Toast } from 'element-plus';
import type { OnJoinRoomData, ServerToClientEvents } from './types';
import { useAgoraMeeting } from './useAgoraMeeting';
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
/** 课程房间名称 */
const courseRoomName = computed(() => String(route.query.name || ''));
/** 用户长 ID */
const longUserId = computed(() => userStore.userInfo?.id || 0);
/** 用户名称 */
const userName = computed(() => userStore.userInfo?.name || '用户');
/** 作业 ID */
const homeworkId = computed(() => Number(route.query.homeworkId || 0));
/** 课程房间ID */
const courseRoomId = computed(() => `n_${homeworkId.value}`);
/** 课堂状态not_started表示未开始 / in_class表示上课中 / finished表示已结束 */
const classStatus = ref<'finished' | 'in_class' | 'not_started'>('not_started');
/** 用户的短id */
const shortUid = ref<number>(0);
/** 房间号 */
const roomId = ref<string>('');
/** 老师短 UID */
const teacherUid = ref<number>(0);
const meeting = useAgoraMeeting(
courseRoomId,
userName,
shortUid,
{
onTokenWillExpire: () => {
Toast.warning('Token 即将过期,正在自动刷新...');
},
onTokenExpired: () => {
Toast.error('Token 已过期,请重新加入会议');
},
onConnectionStateChange: (state: string) => {
if (state === 'DISCONNECTED') {
Toast.warning('网络连接已断开');
} else if (state === 'RECONNECTING') {
Toast.info('正在重新连接...');
}
},
onError: (error: Error) => {
Toast.error(error.message || '操作失败');
},
},
{ disableLocalTracks: true }
);
const socket = useWebSocket();
const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId);
const { networkQuality, selectedUserUid, leaveChannel, cleanup } = meeting;
/** 网络质量样式类名 */
const networkQualityClass = computed(() => networkQuality.value);
/** 网络状态文本描述 */
const networkStatusText = computed(() => {
const qualityMap: Record<string, string> = {
good: '网络良好',
fair: '网络一般',
poor: '网络较差',
};
return qualityMap[networkQuality.value] || '网络未知';
});
/**
* 监控端学生用户列表(只展示学生,不包含老师)
*/
const studentUsers = computed(() => {
const users = meeting.remoteUsers.value as (IAgoraRTCRemoteUser & { userName?: string })[];
return users.filter((u) => (teacherUid.value ? u.uid !== teacherUid.value : true));
});
/**
* 根据学生人数计算宫格排数(最多 4 排)
*/
const gridRows = computed(() => {
const count = studentUsers.value.length;
if (count <= 1) {
return 1;
}
if (count <= 4) {
return 2;
}
if (count <= 10) {
return 3;
}
return 4;
});
/**
* 根据学生人数与排数计算列数
*/
const gridCols = computed(() => {
const count = studentUsers.value.length;
const rows = gridRows.value || 1;
return Math.max(1, Math.ceil(count / rows));
});
type MeetingWsPacket = Parameters<ServerToClientEvents['message']>[0];
/**
* 离开监控端
*/
async function handleLeave(): Promise<void> {
try {
socket.off('message', onSocketMessage);
try {
meetingSocket.leaveRoom(socket);
} catch {}
destroyMeetingSocket();
await cleanup();
router.back();
} catch (error) {
console.error('离开监控端失败:', error);
}
}
/**
* WebSocket 消息处理
* @param packet - 消息包
*/
function onSocketMessage(packet: MeetingWsPacket) {
switch (packet.type) {
case 'sev_join_room': {
const data = packet.data;
if (data?.roomId && !roomId.value) {
roomId.value = data.roomId;
}
if (data?.classStatus) {
classStatus.value = data.classStatus;
}
if (typeof data?.teacherUid === 'number') {
teacherUid.value = data.teacherUid;
}
break;
}
case 'sev_set_main_video': {
selectedUserUid.value = packet.data.targetUid;
break;
}
case 'sev_class_started': {
classStatus.value = 'in_class';
meetingSocket
.joinRoom(socket)
.then(async (roomData: OnJoinRoomData) => {
roomId.value = roomData.roomId;
shortUid.value = roomData.shortUid;
teacherUid.value = roomData.teacherUid;
await meeting.initAndJoin(roomData);
})
.catch((e: unknown) => {
if (e instanceof Error && e.message) {
Toast.error(e.message);
return;
}
Toast.error('加入失败');
});
break;
}
case 'sev_class_ended': {
classStatus.value = 'finished';
leaveChannel().catch(() => {});
break;
}
case 'sev_kick_user':
case 'sev_device_conflict': {
Toast.warning('监控端已断开,请重新进入');
handleLeave();
break;
}
default:
break;
}
}
socket.on('message', onSocketMessage);
/**
* 页面挂载后加入房间
*/
onMounted(async () => {
try {
const roomData = await meetingSocket.joinRoom(socket);
roomId.value = roomData.roomId;
shortUid.value = roomData.shortUid;
teacherUid.value = roomData.teacherUid;
classStatus.value = roomData?.classStatus || 'not_started';
if (classStatus.value === 'in_class') {
await meeting.initAndJoin(roomData);
}
} catch (error) {
console.error('加入监控端失败:', error);
const errorMessage = error instanceof Error ? error.message : '加入监控端失败';
Toast.error(errorMessage);
router.back();
}
});
/**
* 页面卸载清理
*/
onUnmounted(async () => {
socket.off('message', onSocketMessage);
await cleanup();
try {
meetingSocket.leaveRoom(socket);
} catch {}
destroyMeetingSocket();
});
</script>
<style lang="scss" scoped>
.page {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100vh;
padding: 15px;
overflow: hidden;
.page-content {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
background: rgba(255, 255, 255, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1);
}
}
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px 24px;
}
.room-info {
display: flex;
gap: 12px;
align-items: center;
}
.room-icon {
width: 24px;
height: 24px;
color: #667eea;
}
.room-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.room-name {
font-size: 16px;
font-weight: 600;
color: #000000;
}
.room-id {
font-size: 12px;
color: #666666;
}
.network-status {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
&.good {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
&.fair {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
&.poor {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
}
.network-icon {
width: 18px;
height: 18px;
}
.network-text {
font-size: 13px;
font-weight: 500;
}
.monitor-container {
position: relative;
flex: 1;
width: 100%;
padding: 16px;
overflow: hidden;
}
.empty-students {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.grid {
display: grid;
grid-template-rows: repeat(var(--grid-rows), 1fr);
grid-template-columns: repeat(var(--grid-cols), 1fr);
gap: 12px;
width: 100%;
height: 100%;
}
.grid-item {
position: relative;
overflow: hidden;
background: rgba(0, 0, 0, 0.85);
border-radius: 16px;
}
.player-wrapper {
width: 100%;
height: 100%;
}
.player-wrapper :deep(video) {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-label {
position: absolute;
bottom: 10px;
left: 10px;
padding: 6px 10px;
font-size: 12px;
color: #ffffff;
user-select: none;
background: rgba(0, 0, 0, 0.55);
border-radius: 10px;
}
</style>