feat(meeting): 优化会议室页面体验并完善音视频功能
- 添加房间加入加载状态提示界面 - 实现学生ID到姓名的映射显示功能 - 优化音视频订阅处理逻辑增强稳定性 - 统一教师端和监控端的加载状态管理 - 完善学生列表获取和排序机制 - 修复用户发布事件处理中的潜在错误
This commit is contained in:
@ -15,7 +15,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="monitor-container">
|
<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">
|
<div v-else-if="studentUsers.length === 0" class="empty-students">
|
||||||
<el-empty description="暂无学生画面"></el-empty>
|
<el-empty description="暂无学生画面"></el-empty>
|
||||||
</div>
|
</div>
|
||||||
@ -30,7 +40,7 @@
|
|||||||
<div v-for="u in studentUsers" :key="String(u.uid)" class="grid-item">
|
<div v-for="u in studentUsers" :key="String(u.uid)" class="grid-item">
|
||||||
<div :id="`player-${u.uid}`" class="player-wrapper"></div>
|
<div :id="`player-${u.uid}`" class="player-wrapper"></div>
|
||||||
<div class="video-label">
|
<div class="video-label">
|
||||||
<span>{{ u.userName || `用户 ${u.uid}` }}</span>
|
<span>{{ shortIdToNameMap.get(Number(u.uid)) || `用户 ${u.uid}` }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -43,12 +53,14 @@
|
|||||||
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng';
|
||||||
import IconMdiWifi from '~icons/mdi/wifi';
|
import IconMdiWifi from '~icons/mdi/wifi';
|
||||||
import IconMdiVideo from '~icons/mdi/video';
|
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 { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
|
import type { ServerToClientEvents } from './types';
|
||||||
import type { OnJoinRoomData, ServerToClientEvents } from './types';
|
|
||||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||||
|
import { getStudentDetailsAxios } from '@/api/meeting';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -74,10 +86,8 @@
|
|||||||
/** 老师短 UID */
|
/** 老师短 UID */
|
||||||
const teacherUid = ref<number>(0);
|
const teacherUid = ref<number>(0);
|
||||||
|
|
||||||
/** 监控端是否发布本地音视频(默认不发布) */
|
/** 监控端永远禁用本地音视频轨道(只订阅不发布) */
|
||||||
const isPublishLocalTracks = ref<boolean>(false);
|
const disableLocalTracks = computed(() => true);
|
||||||
/** 是否禁用本地音视频轨道 */
|
|
||||||
const disableLocalTracks = computed(() => !isPublishLocalTracks.value);
|
|
||||||
|
|
||||||
const meeting = useAgoraMeeting(
|
const meeting = useAgoraMeeting(
|
||||||
courseRoomId,
|
courseRoomId,
|
||||||
@ -115,6 +125,24 @@
|
|||||||
const roomEndTimestamp = ref<number | null>(null);
|
const roomEndTimestamp = ref<number | null>(null);
|
||||||
/** 是否正在弹出续期窗口 */
|
/** 是否正在弹出续期窗口 */
|
||||||
const isRenewPrompting = ref<boolean>(false);
|
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(() => {
|
watchEffect(() => {
|
||||||
if (roomEndTimestamp.value === null && endTimestamp.value) {
|
if (roomEndTimestamp.value === null && endTimestamp.value) {
|
||||||
@ -257,32 +285,31 @@
|
|||||||
selectedUserUid.value = packet.data.targetUid;
|
selectedUserUid.value = packet.data.targetUid;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sev_student_order_changed': {
|
// 学生排序变更事件 (类型定义待添加)
|
||||||
// 学生排序变更,根据短 ID 数组重新排序学生列表
|
// case 'sev_student_order_changed': {
|
||||||
const newOrder = packet.data.studentShortIds;
|
// const newOrder = packet.data.studentShortIds;
|
||||||
if (Array.isArray(newOrder) && newOrder.length > 0) {
|
// if (Array.isArray(newOrder) && newOrder.length > 0) {
|
||||||
// 这里可以添加排序逻辑,或者让 Agora 自动处理
|
// console.log('[监控端] 学生排序已更新:', newOrder);
|
||||||
console.log('[监控端] 学生排序已更新:', newOrder);
|
// }
|
||||||
}
|
// break;
|
||||||
break;
|
// }
|
||||||
}
|
|
||||||
case 'sev_class_started': {
|
case 'sev_class_started': {
|
||||||
classStatus.value = 'in_class';
|
classStatus.value = 'in_class';
|
||||||
meetingSocket
|
console.log('[监控端] 收到上课消息');
|
||||||
.joinRoom(socket)
|
|
||||||
.then(async (roomData: OnJoinRoomData) => {
|
// 如果已经在上课状态,忽略
|
||||||
roomId.value = roomData.roomId;
|
if (meeting.client.value) {
|
||||||
shortUid.value = roomData.shortUid;
|
console.log('[监控端] Agora 已初始化,仅更新学生信息');
|
||||||
teacherUid.value = roomData.teacherUid;
|
fetchStudentList();
|
||||||
await meeting.initAndJoin(roomData);
|
break;
|
||||||
})
|
}
|
||||||
.catch((e: unknown) => {
|
|
||||||
if (e instanceof Error && e.message) {
|
// 第一次上课,初始化 Agora
|
||||||
Toast.error(e.message);
|
console.log('[监控端] 开始初始化 Agora...');
|
||||||
return;
|
handleClassStarted().catch((error) => {
|
||||||
}
|
console.error('[监控端] 初始化 Agora 失败:', error);
|
||||||
Toast.error('加入失败');
|
Toast.error('加入会议失败');
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sev_class_ended': {
|
case 'sev_class_ended': {
|
||||||
@ -313,8 +340,16 @@
|
|||||||
shortUid.value = roomData.shortUid;
|
shortUid.value = roomData.shortUid;
|
||||||
teacherUid.value = roomData.teacherUid;
|
teacherUid.value = roomData.teacherUid;
|
||||||
classStatus.value = roomData?.classStatus || 'not_started';
|
classStatus.value = roomData?.classStatus || 'not_started';
|
||||||
|
// 标记已加入房间
|
||||||
|
isRoomJoined.value = true;
|
||||||
|
|
||||||
if (classStatus.value === 'in_class') {
|
if (classStatus.value === 'in_class') {
|
||||||
|
console.log('[监控端] 课程已开始,初始化 Agora...');
|
||||||
await meeting.initAndJoin(roomData);
|
await meeting.initAndJoin(roomData);
|
||||||
|
// 获取学生信息列表
|
||||||
|
await fetchStudentList();
|
||||||
|
} else {
|
||||||
|
console.log('[监控端] 等待老师上课...');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加入监控端失败:', error);
|
console.error('加入监控端失败:', error);
|
||||||
@ -335,6 +370,51 @@
|
|||||||
} catch {}
|
} catch {}
|
||||||
destroyMeetingSocket();
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@ -451,6 +531,21 @@
|
|||||||
height: 100%;
|
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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(var(--grid-rows), 1fr);
|
grid-template-rows: repeat(var(--grid-rows), 1fr);
|
||||||
|
|||||||
@ -17,8 +17,18 @@
|
|||||||
<div v-if="isHost" class="host-badge">创建者</div>
|
<div v-if="isHost" class="host-badge">创建者</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-container">
|
<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;">
|
<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 }">
|
<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 :id="user.isLocal ? 'player-local' : `player-${user.uid}`" class="player-wrapper"></div>
|
||||||
<div class="user-label">
|
<div class="user-label">
|
||||||
<icon-mdi-account v-if="user.isLocal" class="local-icon"></icon-mdi-account>
|
<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">
|
<span v-if="!user.isLocal" class="stream-type-badge">
|
||||||
{{ getUserStreamType(user.uid) === 0 ? '高' : '低' }}
|
{{ getUserStreamType(user.uid) === 0 ? '高' : '低' }}
|
||||||
@ -162,6 +172,7 @@
|
|||||||
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
|
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
|
||||||
import IconMdiWifi from '~icons/mdi/wifi';
|
import IconMdiWifi from '~icons/mdi/wifi';
|
||||||
import IconMdiFormatListBulleted from '~icons/mdi/format-list-bulleted';
|
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 { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useStreamType } from './useStreamType';
|
import { useStreamType } from './useStreamType';
|
||||||
@ -169,7 +180,6 @@
|
|||||||
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
|
import type { DisplayUser, OnJoinRoomData, ServerToClientEvents } from './types';
|
||||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||||
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
|
|
||||||
import { getBlacklistAxios, getStudentDetailsAxios, removeFromBlacklistAxios } from '@/api/meeting';
|
import { getBlacklistAxios, getStudentDetailsAxios, removeFromBlacklistAxios } from '@/api/meeting';
|
||||||
import StudentSortDialog from './student-sort-dialog.vue';
|
import StudentSortDialog from './student-sort-dialog.vue';
|
||||||
|
|
||||||
@ -288,6 +298,33 @@
|
|||||||
const screenShareLoading = ref(false);
|
const screenShareLoading = ref(false);
|
||||||
/** 房间结束时间(毫秒时间戳) */
|
/** 房间结束时间(毫秒时间戳) */
|
||||||
const roomEndTimestamp = ref<number | null>(null);
|
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);
|
const isRenewPrompting = ref<boolean>(false);
|
||||||
|
|
||||||
@ -483,21 +520,27 @@
|
|||||||
}
|
}
|
||||||
case 'sev_class_started': {
|
case 'sev_class_started': {
|
||||||
classStatus.value = 'in_class';
|
classStatus.value = 'in_class';
|
||||||
Toast.success('老师已上课,准备加入音视频');
|
|
||||||
meetingSocket
|
if (meeting.client.value) {
|
||||||
.joinRoom(socket)
|
fetchStudentList();
|
||||||
.then(async (roomData: OnJoinRoomData) => {
|
} else {
|
||||||
roomId.value = roomData.roomId;
|
Toast.success('老师已上课,准备加入音视频');
|
||||||
shortUid.value = roomData.shortUid;
|
meetingSocket
|
||||||
await meeting.initAndJoin(roomData);
|
.joinRoom(socket)
|
||||||
})
|
.then(async (roomData: OnJoinRoomData) => {
|
||||||
.catch((e: unknown) => {
|
roomId.value = roomData.roomId;
|
||||||
if (e instanceof Error && e.message) {
|
shortUid.value = roomData.shortUid;
|
||||||
Toast.error(e.message);
|
await meeting.initAndJoin(roomData);
|
||||||
return;
|
await fetchStudentList();
|
||||||
}
|
})
|
||||||
Toast.error('加入失败');
|
.catch((e: unknown) => {
|
||||||
});
|
if (e instanceof Error && e.message) {
|
||||||
|
Toast.error(e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Toast.error('加入失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'sev_class_ended': {
|
case 'sev_class_ended': {
|
||||||
@ -558,17 +601,15 @@
|
|||||||
return qualityMap[networkQuality.value] || '网络未知';
|
return qualityMap[networkQuality.value] || '网络未知';
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 显示的用户列表(包含本地用户和选中的主视频用户排序) */
|
/** 显示的用户列表 (包含本地用户和选中的主视频用户排序) */
|
||||||
const displayUsers = computed<DisplayUser[]>(() => {
|
const displayUsers = computed<DisplayUser[]>(() => {
|
||||||
const users: DisplayUser[] = (meeting.remoteUsers.value as DisplayUser[]) || [];
|
const users: DisplayUser[] = (meeting.remoteUsers.value as DisplayUser[]) || [];
|
||||||
const result: DisplayUser[] = [...users];
|
const result: DisplayUser[] = [...users];
|
||||||
// 只有当声网分配了真实 uid 后才显示本地用户(避免 uid 变化导致 DOM 重建)
|
|
||||||
// 使用 meeting.localUser 确保响应式追踪
|
|
||||||
const localUid = meeting.localUser.uid;
|
const localUid = meeting.localUser.uid;
|
||||||
if (localUid && localUid !== 0) {
|
if (localUid && localUid !== 0) {
|
||||||
result.push(meeting.localUser as DisplayUser);
|
result.push(meeting.localUser as DisplayUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedUserUid.value) {
|
if (selectedUserUid.value) {
|
||||||
const idx = result.findIndex((u) => u.uid === selectedUserUid.value);
|
const idx = result.findIndex((u) => u.uid === selectedUserUid.value);
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
@ -584,17 +625,6 @@
|
|||||||
const blacklistVisible = ref(false);
|
const blacklistVisible = ref(false);
|
||||||
/** 学生列表排序弹窗可见性 */
|
/** 学生列表排序弹窗可见性 */
|
||||||
const studentSortDialogVisible = 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> {
|
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> {
|
async function removeFromBlacklist(itemShortUid: number): Promise<void> {
|
||||||
if (!roomId.value) {
|
if (!roomId.value) {
|
||||||
@ -845,11 +898,14 @@
|
|||||||
roomId.value = roomData.roomId;
|
roomId.value = roomData.roomId;
|
||||||
// 更新 Hook 内部维护的短 UID(会自动同步到 localUser.uid)
|
// 更新 Hook 内部维护的短 UID(会自动同步到 localUser.uid)
|
||||||
shortUid.value = roomData.shortUid;
|
shortUid.value = roomData.shortUid;
|
||||||
|
// 标记已加入房间
|
||||||
|
isRoomJoined.value = true;
|
||||||
|
|
||||||
// 然后初始化声网会议(使用获取到的短 UID)
|
// 然后初始化声网会议(使用获取到的短 UID)
|
||||||
classStatus.value = roomData?.classStatus || 'not_started';
|
classStatus.value = roomData?.classStatus || 'not_started';
|
||||||
if (classStatus.value === 'in_class') {
|
if (classStatus.value === 'in_class') {
|
||||||
await meeting.initAndJoin(roomData);
|
await meeting.initAndJoin(roomData);
|
||||||
|
await fetchStudentList();
|
||||||
} else {
|
} else {
|
||||||
Toast.info?.('等待老师上课...');
|
Toast.info?.('等待老师上课...');
|
||||||
}
|
}
|
||||||
@ -890,6 +946,21 @@
|
|||||||
animation: spin 1s linear infinite;
|
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 {
|
.page {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -245,43 +245,54 @@ export function useAgoraMeeting(
|
|||||||
console.log('【声网事件】user-published:', user.uid, '媒体类型:', mediaType);
|
console.log('【声网事件】user-published:', user.uid, '媒体类型:', mediaType);
|
||||||
console.log('【声网事件】user 对象:', user);
|
console.log('【声网事件】user 对象:', user);
|
||||||
|
|
||||||
await client.value?.subscribe(user, mediaType);
|
// ✅ 验证 user 对象是否有效
|
||||||
|
if (!user || typeof user !== 'object') {
|
||||||
// 双流模式优化:默认使用低流 (1) 节省流量,只有主视频使用高流 (0)
|
console.error('[声网] user 对象无效:', user);
|
||||||
const isMainVideo = selectedUserUid.value === user.uid;
|
return;
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ 播放远程音频(声网音频轨道需要手动播放)
|
try {
|
||||||
if (mediaType === 'audio') {
|
await client.value?.subscribe(user, mediaType);
|
||||||
console.log('【声网】收到远程音频,audioTrack:', user.audioTrack);
|
|
||||||
if (user.audioTrack) {
|
// 双流模式优化:默认使用低流 (1) 节省流量,只有主视频使用高流 (0)
|
||||||
try {
|
const isMainVideo = selectedUserUid.value === user.uid;
|
||||||
user.audioTrack.play();
|
const streamType: 0 | 1 = isMainVideo ? 0 : 1;
|
||||||
console.log('【声网】✅ 远程音频已播放:', user.uid);
|
if (mediaType === 'video') {
|
||||||
} catch (error) {
|
await client.value?.setRemoteVideoStreamType(user.uid, streamType);
|
||||||
console.error('【声网】❌ 远程音频播放失败:', error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('【声网】❌ user.audioTrack 不存在!');
|
|
||||||
}
|
}
|
||||||
}
|
console.log('【声网】订阅成功:', user.uid, '类型:', mediaType, isMainVideo ? '主视频 (高流)' : '小视频 (低流)');
|
||||||
|
|
||||||
// ✅ 只在第一次添加用户到 remoteUsers,防止网络波动导致的重复触发
|
// ✅ 播放远程视频到普通播放器(默认使用 player-${uid},可通过 options 关闭)
|
||||||
const isNewUser = !remoteUsers.value.find((u) => u.uid === user.uid);
|
if (mediaType === 'video' && (options?.autoPlayRemoteVideo ?? true)) {
|
||||||
if (isNewUser) {
|
playRemoteVideo(user, `player-${user.uid}`);
|
||||||
remoteUsers.value.push(user);
|
}
|
||||||
console.log('[声网] 新用户加入,当前用户数:', remoteUsers.value.length);
|
|
||||||
|
// ✅ 播放远程音频(声网音频轨道需要手动播放)
|
||||||
|
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 订阅的视频流类型发生改变回调 ((高码率、高分辨率)变为视频小流(低码率、低分辨率),或视频小流变为视频大流。) */
|
/** 订阅的视频流类型发生改变回调 ((高码率、高分辨率)变为视频小流(低码率、低分辨率),或视频小流变为视频大流。) */
|
||||||
|
|||||||
Reference in New Issue
Block a user