feat(meeting): 优化会议室页面体验并完善音视频功能

- 添加房间加入加载状态提示界面
- 实现学生ID到姓名的映射显示功能
- 优化音视频订阅处理逻辑增强稳定性
- 统一教师端和监控端的加载状态管理
- 完善学生列表获取和排序机制
- 修复用户发布事件处理中的潜在错误
This commit is contained in:
2026-03-14 22:17:20 +08:00
parent 0e88826a9c
commit 5873cda8f5
3 changed files with 274 additions and 97 deletions

View File

@ -15,7 +15,17 @@
</div>
</div>
<div class="monitor-container">
<el-empty v-if="classStatus !== 'in_class'" :description="classStatus === 'not_started' ? '等待老师上课...' : '老师已下课,等待重新上课...'"></el-empty>
<!-- 加载中状态 -->
<div v-if="!isRoomJoined" class="loading-container">
<el-icon class="is-loading" :size="50">
<icon-mdi-loading></icon-mdi-loading>
</el-icon>
<p class="loading-text">正在加入房间...</p>
</div>
<el-empty
v-else-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>
@ -30,7 +40,7 @@
<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>
<span>{{ shortIdToNameMap.get(Number(u.uid)) || `用户 ${u.uid}` }}</span>
</div>
</div>
</div>
@ -43,12 +53,14 @@
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
import IconMdiWifi from '~icons/mdi/wifi';
import IconMdiVideo from '~icons/mdi/video';
import IconMdiLoading from '~icons/mdi/loading';
import { ElIcon, ElMessageBox, ElMessage as Toast } from 'element-plus';
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
import type { OnJoinRoomData, ServerToClientEvents } from './types';
import type { ServerToClientEvents } from './types';
import { useAgoraMeeting } from './useAgoraMeeting';
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
import { getStudentDetailsAxios } from '@/api/meeting';
const route = useRoute();
const router = useRouter();
@ -74,10 +86,8 @@
/** 老师短 UID */
const teacherUid = ref<number>(0);
/** 监控端是否发布本地音视频(默认不发布) */
const isPublishLocalTracks = ref<boolean>(false);
/** 是否禁用本地音视频轨道 */
const disableLocalTracks = computed(() => !isPublishLocalTracks.value);
/** 监控端永远禁用本地音视频轨道(只订阅不发布) */
const disableLocalTracks = computed(() => true);
const meeting = useAgoraMeeting(
courseRoomId,
@ -115,6 +125,24 @@
const roomEndTimestamp = ref<number | null>(null);
/** 是否正在弹出续期窗口 */
const isRenewPrompting = ref<boolean>(false);
/** 是否已加入房间 */
const isRoomJoined = ref(false);
/** 学生长 ID 到姓名的映射 */
const studentNameMap = ref<Map<number, string>>(new Map());
/** 用于排序的学生列表 */
const studentListForSort = ref<Array<{ longId: number; studentName: string; shortId: number }>>([]);
/** 学生短 ID 到姓名的映射 (用于显示) */
const shortIdToNameMap = computed(() => {
const map = new Map<number, string>();
// 从 studentListForSort 中构建 shortId -> name 的映射
studentListForSort.value.forEach((student) => {
map.set(student.shortId, student.studentName);
});
return map;
});
watchEffect(() => {
if (roomEndTimestamp.value === null && endTimestamp.value) {
@ -257,32 +285,31 @@
selectedUserUid.value = packet.data.targetUid;
break;
}
case 'sev_student_order_changed': {
// 学生排序变更,根据短 ID 数组重新排序学生列表
const newOrder = packet.data.studentShortIds;
if (Array.isArray(newOrder) && newOrder.length > 0) {
// 这里可以添加排序逻辑,或者让 Agora 自动处理
console.log('[监控端] 学生排序已更新:', newOrder);
}
break;
}
// 学生排序变更事件 (类型定义待添加)
// case 'sev_student_order_changed': {
// const newOrder = packet.data.studentShortIds;
// if (Array.isArray(newOrder) && newOrder.length > 0) {
// console.log('[监控端] 学生排序已更新:', newOrder);
// }
// 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('加入失败');
});
console.log('[监控端] 收到上课消息');
// 如果已经在上课状态,忽略
if (meeting.client.value) {
console.log('[监控端] Agora 已初始化,仅更新学生信息');
fetchStudentList();
break;
}
// 第一次上课,初始化 Agora
console.log('[监控端] 开始初始化 Agora...');
handleClassStarted().catch((error) => {
console.error('[监控端] 初始化 Agora 失败:', error);
Toast.error('加入会议失败');
});
break;
}
case 'sev_class_ended': {
@ -313,8 +340,16 @@
shortUid.value = roomData.shortUid;
teacherUid.value = roomData.teacherUid;
classStatus.value = roomData?.classStatus || 'not_started';
// 标记已加入房间
isRoomJoined.value = true;
if (classStatus.value === 'in_class') {
console.log('[监控端] 课程已开始,初始化 Agora...');
await meeting.initAndJoin(roomData);
// 获取学生信息列表
await fetchStudentList();
} else {
console.log('[监控端] 等待老师上课...');
}
} catch (error) {
console.error('加入监控端失败:', error);
@ -335,6 +370,51 @@
} catch {}
destroyMeetingSocket();
});
/**
* 获取学生信息列表
*/
async function fetchStudentList(): Promise<void> {
if (!homeworkId.value || !roomId.value) {
return;
}
try {
const result = await getStudentDetailsAxios(homeworkId.value, roomId.value);
if (result.data && Array.isArray(result.data)) {
// 保存完整的学生列表 (包含 shortId)
studentListForSort.value = result.data;
// 构建学生长 ID 到姓名的映射
const longIdMap = new Map<number, string>();
result.data.forEach((student) => {
longIdMap.set(student.longId, student.studentName);
});
studentNameMap.value = longIdMap;
console.log('[监控端] 获取到学生信息:', result.data.length, '人');
}
} catch (error) {
console.error('[监控端] 获取学生列表失败:', error);
}
}
/**
* 处理上课事件 (第一次上课时初始化 Agora)
*/
async function handleClassStarted(): Promise<void> {
try {
const roomData = await meetingSocket.joinRoom(socket);
roomId.value = roomData.roomId;
shortUid.value = roomData.shortUid;
teacherUid.value = roomData.teacherUid;
await meeting.initAndJoin(roomData);
await fetchStudentList();
console.log('[监控端] Agora 初始化完成');
} catch (error) {
console.error('[监控端] 初始化 Agora 失败:', error);
throw error;
}
}
</script>
<style lang="scss" scoped>
@ -451,6 +531,21 @@
height: 100%;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 400px;
.loading-text {
margin-top: 20px;
font-size: 16px;
color: #999;
}
}
.grid {
display: grid;
grid-template-rows: repeat(var(--grid-rows), 1fr);

View File

@ -17,8 +17,18 @@
<div v-if="isHost" class="host-badge">创建者</div>
</div>
<div class="video-container">
<!-- 加载中状态 -->
<div v-if="!isRoomJoined" class="loading-container">
<el-icon class="is-loading" :size="50">
<icon-mdi-loading></icon-mdi-loading>
</el-icon>
<p class="loading-text">正在加入房间...</p>
</div>
<!-- 未开课时显示空状态 -->
<el-empty v-if="classStatus !== 'in_class'" :description="classStatus === 'not_started' ? '等待老师上课...' : '老师已下课,等待重新上课...'"></el-empty>
<el-empty
v-else-if="classStatus !== 'in_class'"
:description="classStatus === 'not_started' ? '等待老师上课...' : '老师已下课,等待重新上课...'"
></el-empty>
<!-- 普通视频网格 -->
<el-scrollbar v-else class="video-scrollbar" viewStyle="width: 100%; min-height: 100%; box-sizing: border-box; padding: 20px;">
<div ref="remotePlayersContainer" class="remote-videos" :class="{ 'compact-mode': isCompact }" :style="{ '--grid-min-size': gridMinSize }">
@ -32,7 +42,7 @@
<div :id="user.isLocal ? 'player-local' : `player-${user.uid}`" class="player-wrapper"></div>
<div class="user-label">
<icon-mdi-account v-if="user.isLocal" class="local-icon"></icon-mdi-account>
<span>{{ user.userName }}</span>
<span>{{ user.isLocal ? user.userName : shortIdToNameMap.get(Number(user.uid)) || `用户${user.uid}` }}</span>
<!-- 流类型标识 -->
<span v-if="!user.isLocal" class="stream-type-badge">
{{ getUserStreamType(user.uid) === 0 ? '' : '' }}
@ -162,6 +172,7 @@
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
import IconMdiWifi from '~icons/mdi/wifi';
import IconMdiFormatListBulleted from '~icons/mdi/format-list-bulleted';
import { ElIcon, ElMessageBox, ElMessage as Toast } from 'element-plus';
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStreamType } from './useStreamType';
@ -169,7 +180,6 @@
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
import { useAgoraMeeting } from './useAgoraMeeting';
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
import { getBlacklistAxios, getStudentDetailsAxios, removeFromBlacklistAxios } from '@/api/meeting';
import StudentSortDialog from './student-sort-dialog.vue';
@ -288,6 +298,33 @@
const screenShareLoading = ref(false);
/** 房间结束时间(毫秒时间戳) */
const roomEndTimestamp = ref<number | null>(null);
/** 是否已加入房间 */
const isRoomJoined = ref(false);
/** 用于排序的学生列表 */
const studentListForSort = ref<Array<{ longId: number; studentName: string; shortId: number }>>([]);
/** 学生长 ID 到姓名的映射 */
const studentNameMap = ref<Map<number, string>>(new Map());
/** 学生短 ID 到姓名的映射 (用于显示) */
const shortIdToNameMap = computed(() => {
const map = new Map<number, string>();
studentNameMap.value.forEach((name, longId) => {
const student = studentListForSort.value.find((s) => s.longId === longId);
if (student) {
map.set(student.shortId, name);
}
});
return map;
});
/** 黑名单列表(包含短 UID 和名称) */
interface BlacklistItem {
shortUid: number;
userName: string;
}
const blacklist = ref<BlacklistItem[]>([]);
/** 是否正在弹出续期窗口 */
const isRenewPrompting = ref<boolean>(false);
@ -483,21 +520,27 @@
}
case 'sev_class_started': {
classStatus.value = 'in_class';
Toast.success('老师已上课,准备加入音视频');
meetingSocket
.joinRoom(socket)
.then(async (roomData: OnJoinRoomData) => {
roomId.value = roomData.roomId;
shortUid.value = roomData.shortUid;
await meeting.initAndJoin(roomData);
})
.catch((e: unknown) => {
if (e instanceof Error && e.message) {
Toast.error(e.message);
return;
}
Toast.error('加入失败');
});
if (meeting.client.value) {
fetchStudentList();
} else {
Toast.success('老师已上课,准备加入音视频');
meetingSocket
.joinRoom(socket)
.then(async (roomData: OnJoinRoomData) => {
roomId.value = roomData.roomId;
shortUid.value = roomData.shortUid;
await meeting.initAndJoin(roomData);
await fetchStudentList();
})
.catch((e: unknown) => {
if (e instanceof Error && e.message) {
Toast.error(e.message);
return;
}
Toast.error('加入失败');
});
}
break;
}
case 'sev_class_ended': {
@ -558,12 +601,10 @@
return qualityMap[networkQuality.value] || '网络未知';
});
/** 显示的用户列表包含本地用户和选中的主视频用户排序 */
/** 显示的用户列表 (包含本地用户和选中的主视频用户排序) */
const displayUsers = computed<DisplayUser[]>(() => {
const users: DisplayUser[] = (meeting.remoteUsers.value as DisplayUser[]) || [];
const result: DisplayUser[] = [...users];
// 只有当声网分配了真实 uid 后才显示本地用户(避免 uid 变化导致 DOM 重建)
// 使用 meeting.localUser 确保响应式追踪
const localUid = meeting.localUser.uid;
if (localUid && localUid !== 0) {
result.push(meeting.localUser as DisplayUser);
@ -584,17 +625,6 @@
const blacklistVisible = ref(false);
/** 学生列表排序弹窗可见性 */
const studentSortDialogVisible = ref(false);
/** 用于排序的学生列表 */
const studentListForSort = ref<Array<{ longId: number; studentName: string; shortId: number }>>([]);
/** 黑名单列表(包含短 UID 和名称) */
interface BlacklistItem {
shortUid: number;
userName: string;
}
const blacklist = ref<BlacklistItem[]>([]);
// 短 UID 变量重命名避免冲突(已在上面定义)
// shortUid 已在 182 行定义,这里不需要重复
/** 打开黑名单抽屉并加载数据 */
async function openBlacklist(): Promise<void> {
@ -638,6 +668,29 @@
}
}
/**
* 获取学生信息列表
*/
async function fetchStudentList(): Promise<void> {
if (!homeworkId.value || !roomId.value) {
return;
}
try {
const result = await getStudentDetailsAxios(homeworkId.value, roomId.value);
if (result.data && Array.isArray(result.data)) {
studentListForSort.value = result.data;
const longIdMap = new Map<number, string>();
result.data.forEach((student) => {
longIdMap.set(student.longId, student.studentName);
});
studentNameMap.value = longIdMap;
}
} catch (error) {
console.error('[教师端] 获取学生列表失败:', error);
}
}
/** 解除黑名单 */
async function removeFromBlacklist(itemShortUid: number): Promise<void> {
if (!roomId.value) {
@ -845,11 +898,14 @@
roomId.value = roomData.roomId;
// 更新 Hook 内部维护的短 UID会自动同步到 localUser.uid
shortUid.value = roomData.shortUid;
// 标记已加入房间
isRoomJoined.value = true;
// 然后初始化声网会议(使用获取到的短 UID
classStatus.value = roomData?.classStatus || 'not_started';
if (classStatus.value === 'in_class') {
await meeting.initAndJoin(roomData);
await fetchStudentList();
} else {
Toast.info?.('等待老师上课...');
}
@ -890,6 +946,21 @@
animation: spin 1s linear infinite;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 400px;
.loading-text {
margin-top: 20px;
font-size: 16px;
color: #999;
}
}
.page {
box-sizing: border-box;
display: flex;

View File

@ -245,43 +245,54 @@ export function useAgoraMeeting(
console.log('【声网事件】user-published:', user.uid, '媒体类型:', mediaType);
console.log('【声网事件】user 对象:', user);
await client.value?.subscribe(user, mediaType);
// 双流模式优化:默认使用低流 (1) 节省流量,只有主视频使用高流 (0)
const isMainVideo = selectedUserUid.value === user.uid;
const streamType: 0 | 1 = isMainVideo ? 0 : 1;
if (mediaType === 'video') {
await client.value?.setRemoteVideoStreamType(user.uid, streamType);
}
console.log('【声网】订阅成功:', user.uid, '类型:', mediaType, isMainVideo ? '主视频 (高流)' : '小视频 (低流)');
// ✅ 播放远程视频到普通播放器(默认使用 player-${uid},可通过 options 关闭)
if (mediaType === 'video' && (options?.autoPlayRemoteVideo ?? true)) {
playRemoteVideo(user, `player-${user.uid}`);
// ✅ 验证 user 对象是否有效
if (!user || typeof user !== 'object') {
console.error('[声网] user 对象无效:', user);
return;
}
// ✅ 播放远程音频(声网音频轨道需要手动播放)
if (mediaType === 'audio') {
console.log('【声网】收到远程音频audioTrack:', user.audioTrack);
if (user.audioTrack) {
try {
user.audioTrack.play();
console.log('【声网】✅ 远程音频已播放:', user.uid);
} catch (error) {
console.error('【声网】❌ 远程音频播放失败:', error);
}
} else {
console.warn('【声网】❌ user.audioTrack 不存在!');
try {
await client.value?.subscribe(user, mediaType);
// 双流模式优化:默认使用低流 (1) 节省流量,只有主视频使用高流 (0)
const isMainVideo = selectedUserUid.value === user.uid;
const streamType: 0 | 1 = isMainVideo ? 0 : 1;
if (mediaType === 'video') {
await client.value?.setRemoteVideoStreamType(user.uid, streamType);
}
}
console.log('【声网】订阅成功:', user.uid, '类型:', mediaType, isMainVideo ? '主视频 (高流)' : '小视频 (低流)');
// ✅ 只在第一次添加用户到 remoteUsers防止网络波动导致的重复触发
const isNewUser = !remoteUsers.value.find((u) => u.uid === user.uid);
if (isNewUser) {
remoteUsers.value.push(user);
console.log('[声网] 新用户加入,当前用户数:', remoteUsers.value.length);
// ✅ 播放远程视频到普通播放器(默认使用 player-${uid},可通过 options 关闭)
if (mediaType === 'video' && (options?.autoPlayRemoteVideo ?? true)) {
playRemoteVideo(user, `player-${user.uid}`);
}
// ✅ 播放远程音频(声网音频轨道需要手动播放)
if (mediaType === 'audio') {
console.log('【声网】收到远程音频audioTrack:', user.audioTrack);
if (user.audioTrack) {
try {
user.audioTrack.play();
console.log('【声网】✅ 远程音频已播放:', user.uid);
} catch (error) {
console.error('【声网】❌ 远程音频播放失败:', error);
}
} else {
console.warn('【声网】❌ user.audioTrack 不存在!');
}
}
// ✅ 只在第一次添加用户到 remoteUsers防止网络波动导致的重复触发
const isNewUser = !remoteUsers.value.find((u) => u.uid === user.uid);
if (isNewUser) {
remoteUsers.value.push(user);
console.log('[声网] 新用户加入,当前用户数:', remoteUsers.value.length);
}
callbacks?.onUserPublished?.(user, mediaType);
} catch (error) {
console.error('[声网] 订阅用户失败:', user.uid, error);
// 不抛出错误,避免影响其他用户
}
callbacks?.onUserPublished?.(user, mediaType);
});
/** 订阅的视频流类型发生改变回调 ((高码率、高分辨率)变为视频小流(低码率、低分辨率),或视频小流变为视频大流。) */