From d09d3645ea61c835d67c1ae4f944283fcd70f347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=E6=98=B5=E7=A7=B0=E9=87=8D=E8=A6=81=E5=90=97O?= Date: Fri, 13 Mar 2026 14:45:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=9B=91=E6=8E=A7?= =?UTF-8?q?=E7=AB=AF=E8=BF=9B=E5=85=A5=E5=B9=B6=E4=BC=98=E5=8C=96=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=80=81=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 允许教师从加入页面选择进入监控端或课堂端 - 修改登录态验证逻辑,仅检查 Redis 中是否存在 token 而非严格相等,以支持同一账号多端登录 - 监控端默认不发布本地音视频轨道,但可通过配置动态启用 - 优化 WebSocket 鉴权失败处理,自动跳转登录页 --- node_api/src/common/guards/auth.guard.ts | 3 +- .../modules/websocket/meeting-auth.guard.ts | 3 +- src/views/meeting/join-room/join-room.vue | 29 ++++++++++++++----- .../meeting-room/meeting-room-monitor.vue | 7 ++++- .../meeting/meeting-room/useAgoraMeeting.ts | 10 +++---- src/views/meeting/meeting-room/websocket.ts | 20 ++----------- 6 files changed, 39 insertions(+), 33 deletions(-) diff --git a/node_api/src/common/guards/auth.guard.ts b/node_api/src/common/guards/auth.guard.ts index 0a0a5d3..2c06fb4 100644 --- a/node_api/src/common/guards/auth.guard.ts +++ b/node_api/src/common/guards/auth.guard.ts @@ -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) { diff --git a/node_api/src/modules/websocket/meeting-auth.guard.ts b/node_api/src/modules/websocket/meeting-auth.guard.ts index 8ac37ff..2c9046e 100644 --- a/node_api/src/modules/websocket/meeting-auth.guard.ts +++ b/node_api/src/modules/websocket/meeting-auth.guard.ts @@ -136,7 +136,8 @@ export class MeetingAuthGuard implements CanActivate { })(); const redisToken = redisObj?.Token || redisObj?.token || ''; - if (redisToken !== token) { + // 因为会有同一个账号同平台登录多个(监控端和教师端使用同一个账号也都是web所以不做过多验证) + if (!redisToken) { throw new WsException('登录态已失效'); } diff --git a/src/views/meeting/join-room/join-room.vue b/src/views/meeting/join-room/join-room.vue index fb0a8d6..bd7b228 100644 --- a/src/views/meeting/join-room/join-room.vue +++ b/src/views/meeting/join-room/join-room.vue @@ -123,7 +123,11 @@ - + @@ -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); } } diff --git a/src/views/meeting/meeting-room/meeting-room-monitor.vue b/src/views/meeting/meeting-room/meeting-room-monitor.vue index 0bae57a..9a5bfd5 100644 --- a/src/views/meeting/meeting-room/meeting-room-monitor.vue +++ b/src/views/meeting/meeting-room/meeting-room-monitor.vue @@ -74,6 +74,11 @@ /** 老师短 UID */ const teacherUid = ref(0); + /** 监控端是否发布本地音视频(默认不发布) */ + const isPublishLocalTracks = ref(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(); diff --git a/src/views/meeting/meeting-room/useAgoraMeeting.ts b/src/views/meeting/meeting-room/useAgoraMeeting.ts index 9d1b072..681f3cf 100644 --- a/src/views/meeting/meeting-room/useAgoraMeeting.ts +++ b/src/views/meeting/meeting-room/useAgoraMeeting.ts @@ -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; } /** @@ -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)[] = []; diff --git a/src/views/meeting/meeting-room/websocket.ts b/src/views/meeting/meeting-room/websocket.ts index 4d7db66..1c1406e 100644 --- a/src/views/meeting/meeting-room/websocket.ts +++ b/src/views/meeting/meeting-room/websocket.ts @@ -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(); } });