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;
if (redisToken !== token) {
// 因为会有同一个账号同平台登录多个监控端和教师端使用同一个账号也都是web所以不做过多验证
if (!redisToken) {
throw new UnauthorizedException('登录态已失效');
}
} catch (e) {

View File

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

View File

@ -123,7 +123,11 @@
<el-space alignment="center" :size="25">
<app-button-full-screen></app-button-full-screen>
<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>
<span>进入课程</span>
</button>
@ -418,7 +422,7 @@
/**
* 处理学生加入房间操作
*/
async function handleJoinRoom() {
async function handleJoinRoom(target: 'classroom' | 'monitor' = 'classroom') {
try {
await formRef.value?.validate();
} catch (err: any) {
@ -428,9 +432,10 @@
// 触发表单验证
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({
name: targetName,
path: targetPath,
query: {
homeworkId: homeworkId.value,
name: courseRoomName.value,
@ -706,13 +711,21 @@
box-shadow: 0 4px 12px #fccd6d;
&:hover:not(:disabled) {
box-shadow: 0 6px 15px #f07f70;
box-shadow: 0 6px 15px rgba(240, 127, 112, 0.5);
transform: translateY(-2px);
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&.btn-monitoring {
width: 220px;
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 */
const teacherUid = ref<number>(0);
/** 监控端是否发布本地音视频(默认不发布) */
const isPublishLocalTracks = ref<boolean>(false);
/** 是否禁用本地音视频轨道 */
const disableLocalTracks = computed(() => !isPublishLocalTracks.value);
const meeting = useAgoraMeeting(
courseRoomId,
userName,
@ -96,7 +101,7 @@
Toast.error(error.message || '操作失败');
},
},
{ disableLocalTracks: true }
{ disableLocalTracks }
);
const socket = useWebSocket();

View File

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

View File

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