- 在路由中新增学生端和监控端页面路由 - 修改加入会议逻辑,根据角色跳转到不同页面 - 新增监控端页面,支持多学生画面宫格布局 - 新增学生端页面,支持主画面显示和本地视频小窗拖拽 - 扩展 useAgoraMeeting hook 支持禁用本地音视频轨道 - 更新悬浮列表窗口的会议和字幕创建函数参数 - 添加课程和评讲信息的存储常量
420 lines
11 KiB
Vue
420 lines
11 KiB
Vue
<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>
|