feat: 支持监控端进入并优化登录态验证

- 允许教师从加入页面选择进入监控端或课堂端
- 修改登录态验证逻辑,仅检查 Redis 中是否存在 token 而非严格相等,以支持同一账号多端登录
- 监控端默认不发布本地音视频轨道,但可通过配置动态启用
- 优化 WebSocket 鉴权失败处理,自动跳转登录页
This commit is contained in:
2026-03-13 14:45:01 +08:00
parent bd31d23bda
commit d09d3645ea
6 changed files with 39 additions and 33 deletions

View File

@ -85,7 +85,8 @@ export class AuthGuard implements CanActivate {
} }
})(); })();
const redisToken = redisObj?.Token || redisObj?.token; const redisToken = redisObj?.Token || redisObj?.token;
if (redisToken !== token) { // 因为会有同一个账号同平台登录多个监控端和教师端使用同一个账号也都是web所以不做过多验证
if (!redisToken) {
throw new UnauthorizedException('登录态已失效'); throw new UnauthorizedException('登录态已失效');
} }
} catch (e) { } catch (e) {

View File

@ -136,7 +136,8 @@ export class MeetingAuthGuard implements CanActivate {
})(); })();
const redisToken = redisObj?.Token || redisObj?.token || ''; const redisToken = redisObj?.Token || redisObj?.token || '';
if (redisToken !== token) { // 因为会有同一个账号同平台登录多个监控端和教师端使用同一个账号也都是web所以不做过多验证
if (!redisToken) {
throw new WsException('登录态已失效'); throw new WsException('登录态已失效');
} }

View File

@ -123,7 +123,11 @@
<el-space alignment="center" :size="25"> <el-space alignment="center" :size="25">
<app-button-full-screen></app-button-full-screen> <app-button-full-screen></app-button-full-screen>
<app-button-back-page></app-button-back-page> <app-button-back-page></app-button-back-page>
<button class="btn btn-primary" @click="handleJoinRoom"> <button v-if="role === 'teacher'" class="btn btn-monitoring" @click="handleJoinRoom('monitor')">
<icon-mdi-login class="btn-icon"></icon-mdi-login>
<span>进入监控</span>
</button>
<button class="btn btn-primary" @click="handleJoinRoom('classroom')">
<icon-mdi-login class="btn-icon"></icon-mdi-login> <icon-mdi-login class="btn-icon"></icon-mdi-login>
<span>进入课程</span> <span>进入课程</span>
</button> </button>
@ -418,7 +422,7 @@
/** /**
* 处理学生加入房间操作 * 处理学生加入房间操作
*/ */
async function handleJoinRoom() { async function handleJoinRoom(target: 'classroom' | 'monitor' = 'classroom') {
try { try {
await formRef.value?.validate(); await formRef.value?.validate();
} catch (err: any) { } catch (err: any) {
@ -428,9 +432,10 @@
// 触发表单验证 // 触发表单验证
console.log('即将跳转到 meeting-room 页面'); console.log('即将跳转到 meeting-room 页面');
const targetName = role.value === 'student' ? 'meeting-room-student' : 'meeting-room'; const targetPath =
role.value === 'student' ? '/meeting/meeting-room-student' : target === 'monitor' ? '/meeting/meeting-room-monitor' : '/meeting/meeting-room';
router.push({ router.push({
name: targetName, path: targetPath,
query: { query: {
homeworkId: homeworkId.value, homeworkId: homeworkId.value,
name: courseRoomName.value, name: courseRoomName.value,
@ -706,13 +711,21 @@
box-shadow: 0 4px 12px #fccd6d; box-shadow: 0 4px 12px #fccd6d;
&:hover:not(:disabled) { &:hover:not(:disabled) {
box-shadow: 0 6px 15px #f07f70; box-shadow: 0 6px 15px rgba(240, 127, 112, 0.5);
transform: translateY(-2px); transform: translateY(-2px);
} }
}
&:disabled { &.btn-monitoring {
cursor: not-allowed; width: 220px;
opacity: 0.5; margin-bottom: 10px;
color: #ffffff;
background: linear-gradient(45deg, #bd62fd, #c7aaff);
box-shadow: 0 4px 12px #c7aaff;
&:hover:not(:disabled) {
box-shadow: 0 6px 15px rgba(189, 98, 253, 0.5);
transform: translateY(-2px);
} }
} }

View File

@ -74,6 +74,11 @@
/** 老师短 UID */ /** 老师短 UID */
const teacherUid = ref<number>(0); const teacherUid = ref<number>(0);
/** 监控端是否发布本地音视频(默认不发布) */
const isPublishLocalTracks = ref<boolean>(false);
/** 是否禁用本地音视频轨道 */
const disableLocalTracks = computed(() => !isPublishLocalTracks.value);
const meeting = useAgoraMeeting( const meeting = useAgoraMeeting(
courseRoomId, courseRoomId,
userName, userName,
@ -96,7 +101,7 @@
Toast.error(error.message || '操作失败'); Toast.error(error.message || '操作失败');
}, },
}, },
{ disableLocalTracks: true } { disableLocalTracks }
); );
const socket = useWebSocket(); const socket = useWebSocket();

View File

@ -17,7 +17,7 @@ import AgoraRTC, {
type NetworkQuality, type NetworkQuality,
type RemoteStreamType, type RemoteStreamType,
} from 'agora-rtc-sdk-ng'; } from 'agora-rtc-sdk-ng';
import { type Ref, computed, nextTick, reactive, ref, shallowRef, watch, watchEffect } from 'vue'; import { type Ref, computed, nextTick, reactive, ref, shallowRef, unref, watch, watchEffect } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types'; import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types';
import { TokenManager } from './useTokenManager'; import { TokenManager } from './useTokenManager';
@ -27,7 +27,7 @@ import { TokenManager } from './useTokenManager';
*/ */
export interface AgoraMeetingOptions { export interface AgoraMeetingOptions {
/** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */ /** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */
disableLocalTracks?: boolean; disableLocalTracks?: Ref<boolean> | boolean;
} }
/** /**
@ -97,7 +97,7 @@ export function useAgoraMeeting(
/** 是否正在共享屏幕 */ /** 是否正在共享屏幕 */
const isScreenSharing = ref(false); const isScreenSharing = ref(false);
/** 是否禁用本地音视频轨道 */ /** 是否禁用本地音视频轨道 */
const disableLocalTracks = Boolean(options?.disableLocalTracks); const disableLocalTracks = computed(() => Boolean(unref(options?.disableLocalTracks ?? false)));
// 同步本地用户名称 // 同步本地用户名称
watchEffect(() => { watchEffect(() => {
@ -372,7 +372,7 @@ export function useAgoraMeeting(
try { try {
FullLoading.show('正在加入会议...'); FullLoading.show('正在加入会议...');
if (!disableLocalTracks) { if (!disableLocalTracks.value) {
// 初始化本地麦克风 // 初始化本地麦克风
try { try {
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({ localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({
@ -424,7 +424,7 @@ export function useAgoraMeeting(
tokenManagerRef.value.setCurrentToken(roomData.tokenInfo); tokenManagerRef.value.setCurrentToken(roomData.tokenInfo);
} }
if (!disableLocalTracks) { if (!disableLocalTracks.value) {
// ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个) // ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个)
const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = []; const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = [];

View File

@ -7,7 +7,7 @@ import { type ComputedRef, ref } from 'vue';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import type { MeetingClientSocket, OnJoinRoomData, SendJoinRoomData, ServerToClientEvents } from './types'; import type { MeetingClientSocket, OnJoinRoomData, SendJoinRoomData, ServerToClientEvents } from './types';
import { getDefaultAuthHeaders } from '@/utils/axios/auth-headers'; import { getDefaultAuthHeaders } from '@/utils/axios/auth-headers';
import router from '@/router/router';
let socket: MeetingClientSocket | null = null; let socket: MeetingClientSocket | null = null;
/** 鉴权失败回调集合 */ /** 鉴权失败回调集合 */
const authFailedHandlers = new Set<(err: Error) => void>(); const authFailedHandlers = new Set<(err: Error) => void>();
@ -56,14 +56,10 @@ export function useWebSocket(): MeetingClientSocket {
console.error('[会议室] WebSocket 连接错误:', error); console.error('[会议室] WebSocket 连接错误:', error);
const msg = String(error?.message || ''); const msg = String(error?.message || '');
if (msg === 'Unauthorized') { if (msg === 'Unauthorized') {
authFailedHandlers.forEach((cb) => {
try {
cb(error as Error);
} catch {}
});
try { try {
socket?.disconnect(); socket?.disconnect();
} catch {} } catch {}
router.replace('/login');
} }
}; };
@ -166,15 +162,7 @@ export function useMeetingSocket(
cleanupConnect(); cleanupConnect();
sendJoin(); sendJoin();
}; };
const onConnectError = (error: any) => {
const msg = String(error?.message || '');
cleanupConnect();
if (msg === 'Unauthorized') {
reject(new Error('连接鉴权失败'));
} else {
reject(new Error('连接服务器失败,请检查网络'));
}
};
const connectTimer = setTimeout(() => { const connectTimer = setTimeout(() => {
cleanupConnect(); cleanupConnect();
reject(new Error('连接服务器超时,请检查网络')); reject(new Error('连接服务器超时,请检查网络'));
@ -183,11 +171,9 @@ export function useMeetingSocket(
/** 清理连接事件监听器 */ /** 清理连接事件监听器 */
function cleanupConnect() { function cleanupConnect() {
ws.off('connect', onConnect); ws.off('connect', onConnect);
ws.off('connect_error', onConnectError);
clearTimeout(connectTimer); clearTimeout(connectTimer);
} }
ws.once('connect', onConnect); ws.once('connect', onConnect);
ws.on('connect_error', onConnectError);
ws.connect(); ws.connect();
} }
}); });