From bd31d23bda12bf79d57dc1053b42db6a042c1dc8 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 13:08:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(meeting):=20=E6=96=B0=E5=A2=9E=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=E7=AB=AF=E5=92=8C=E7=9B=91=E6=8E=A7=E7=AB=AF=E4=BC=9A?= =?UTF-8?q?=E8=AE=AE=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在路由中新增学生端和监控端页面路由 - 修改加入会议逻辑,根据角色跳转到不同页面 - 新增监控端页面,支持多学生画面宫格布局 - 新增学生端页面,支持主画面显示和本地视频小窗拖拽 - 扩展 useAgoraMeeting hook 支持禁用本地音视频轨道 - 更新悬浮列表窗口的会议和字幕创建函数参数 - 添加课程和评讲信息的存储常量 --- .../aa-components/floating-list.vue | 4 +- .../use-tauri-session-storage.ts | 25 +- src/router/router.ts | 16 +- src/utils/constant.ts | 6 + src/utils/tauri/windows-action.ts | 44 +- src/views/meeting/join-room/join-room.vue | 3 +- .../meeting-room/meeting-room-monitor.vue | 419 ++++++++++ .../meeting-room/meeting-room-student.vue | 772 ++++++++++++++++++ ...ting-room.vue => meeting-room-teacher.vue} | 0 .../meeting/meeting-room/useAgoraMeeting.ts | 156 ++-- 10 files changed, 1371 insertions(+), 74 deletions(-) create mode 100644 src/views/meeting/meeting-room/meeting-room-monitor.vue create mode 100644 src/views/meeting/meeting-room/meeting-room-student.vue rename src/views/meeting/meeting-room/{meeting-room.vue => meeting-room-teacher.vue} (100%) diff --git a/src/other_pages/floating-list-window/aa-components/floating-list.vue b/src/other_pages/floating-list-window/aa-components/floating-list.vue index 5c49e56..5858a6c 100644 --- a/src/other_pages/floating-list-window/aa-components/floating-list.vue +++ b/src/other_pages/floating-list-window/aa-components/floating-list.vue @@ -263,7 +263,7 @@ async function openMeeting() { playPromptTone(); - await createMeetingWindow(2, currTutorship.value || null); + await createMeetingWindow(2, currTutorship.value?.id); } /** @@ -271,7 +271,7 @@ */ async function openSubtitle() { playPromptTone(); - await createSubtitleWindow(currTutorship.value || null); + await createSubtitleWindow(); } /** diff --git a/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts b/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts index 4ba07ea..d7c9505 100644 --- a/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts +++ b/src/other_pages/floating-list-window/aa-components/use-tauri-session-storage.ts @@ -1,11 +1,32 @@ import { isObject } from '@/utils/verify'; import { STORAGE_COMMANDS, tauriSessionStorage } from '@/utils/tauri/tauri-session'; -import type { BriCourse, BriTutorship } from '@/api/select-classroom-type'; import type { BriUserInfo } from '@/api/login'; import { tauri_store_page_curr_course, tauri_store_page_curr_tutorship, tauri_store_page_user_info } from '@/utils/constant'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { ref } from 'vue'; +/** + * 课程信息(悬浮列表窗口只关心必要字段) + */ +export type BriCourse = { + /** 课程 ID */ + id: number; + /** 班级 ID */ + classId: number; + /** 其他字段 */ + [key: string]: unknown; +}; + +/** + * 评讲信息(悬浮列表窗口只关心必要字段) + */ +export type BriTutorship = { + /** 评讲 ID */ + id: number; + /** 其他字段 */ + [key: string]: unknown; +}; + /** * useTauriSessionStorage */ @@ -66,7 +87,7 @@ export function useTauriSessionStorage() { }); tauriSessionStorage.get(tauri_store_page_curr_tutorship).then((value) => { const obj = isObject(value) ? value : null; - if (obj && 'id') { + if (obj && 'id' in obj) { currTutorship.value = obj as unknown as BriTutorship; } }); diff --git a/src/router/router.ts b/src/router/router.ts index d276ea5..4e5945e 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -54,8 +54,20 @@ export function getLayoutRoutesList(): RouteRecordRaw[] { { path: 'meeting-room', name: 'meeting-room', - component: () => import('@/views/meeting/meeting-room/meeting-room.vue'), - meta: { title: '会议 - 会议房间', affix: false, noCache: true }, + component: () => import('@/views/meeting/meeting-room/meeting-room-teacher.vue'), + meta: { title: '会议 - 教室端', affix: false, noCache: true }, + }, + { + path: 'meeting-room-student', + name: 'meeting-room-student', + component: () => import('@/views/meeting/meeting-room/meeting-room-student.vue'), + meta: { title: '会议 - 学生端', affix: false, noCache: true }, + }, + { + path: 'meeting-room-monitor', + name: 'meeting-room-monitor', + component: () => import('@/views/meeting/meeting-room/meeting-room-monitor.vue'), + meta: { title: '会议 - 监控端', affix: false, noCache: true }, }, ], }, diff --git a/src/utils/constant.ts b/src/utils/constant.ts index 9f758a1..17e52f5 100644 --- a/src/utils/constant.ts +++ b/src/utils/constant.ts @@ -11,6 +11,12 @@ export const MAIN_WINDOW_HEIGHT = 700; /** 存储用户信息 */ export const tauri_store_page_user_info = 'tauri_store_page_user_info'; +/** 存储当前课程信息 */ +export const tauri_store_page_curr_course = 'tauri_store_page_curr_course'; + +/** 存储当前评讲信息 */ +export const tauri_store_page_curr_tutorship = 'tauri_store_page_curr_tutorship'; + /** 存储悬浮球页面动作 */ export const tauri_floating_list_page_action = 'tauri_floating_list_page_action'; diff --git a/src/utils/tauri/windows-action.ts b/src/utils/tauri/windows-action.ts index 8f37a6a..f92a9d4 100644 --- a/src/utils/tauri/windows-action.ts +++ b/src/utils/tauri/windows-action.ts @@ -8,13 +8,55 @@ import { ElMessage as Toast } from 'element-plus'; */ export async function createFloatListWindow(pageType: 'interaction' | 'tutorship') { try { - await invoke('create_float_list_window', { pageType }); + await invoke('create_float_list_window', { page_type: pageType }); } catch (error: any) { console.error('创建悬浮球窗口时出错:', error); Toast.error('悬浮球创建失败'); } } +/** + * 创建主窗口 + */ +export async function createMainWindow() { + try { + await invoke('create_main_window'); + } catch (error: any) { + console.error('创建主窗口时出错:', error); + Toast.error('创建主窗口失败'); + } +} + +/** + * 创建会议窗口 + * @param windowType - 窗口类型 + * @param courseId - 课程ID + */ +export async function createMeetingWindow(windowType: number, courseId?: number) { + try { + await invoke('create_meeting_window', { window_type: windowType, course_id: typeof courseId === 'number' ? courseId : null }); + } catch (error: any) { + console.error('创建会议窗口时出错:', error); + Toast.error('创建会议窗口失败'); + } +} + +/** + * 创建画板窗口 + */ +export function createDrawingBoardWindow(): Promise { + Toast.warning('当前版本暂不支持画板窗口'); + return Promise.resolve(false); +} + +/** + * 创建实时字幕窗口 + */ +export function createSubtitleWindow(): Promise { + Toast.warning('当前版本暂不支持实时字幕窗口'); + return Promise.resolve(false); +} + /** * 调整窗口位置和大小 */ diff --git a/src/views/meeting/join-room/join-room.vue b/src/views/meeting/join-room/join-room.vue index e763d90..fb0a8d6 100644 --- a/src/views/meeting/join-room/join-room.vue +++ b/src/views/meeting/join-room/join-room.vue @@ -428,8 +428,9 @@ // 触发表单验证 console.log('即将跳转到 meeting-room 页面'); + const targetName = role.value === 'student' ? 'meeting-room-student' : 'meeting-room'; router.push({ - name: 'meeting-room', + name: targetName, query: { homeworkId: homeworkId.value, name: courseRoomName.value, diff --git a/src/views/meeting/meeting-room/meeting-room-monitor.vue b/src/views/meeting/meeting-room/meeting-room-monitor.vue new file mode 100644 index 0000000..0bae57a --- /dev/null +++ b/src/views/meeting/meeting-room/meeting-room-monitor.vue @@ -0,0 +1,419 @@ + + + + + diff --git a/src/views/meeting/meeting-room/meeting-room-student.vue b/src/views/meeting/meeting-room/meeting-room-student.vue new file mode 100644 index 0000000..60953b4 --- /dev/null +++ b/src/views/meeting/meeting-room/meeting-room-student.vue @@ -0,0 +1,772 @@ + + + + + diff --git a/src/views/meeting/meeting-room/meeting-room.vue b/src/views/meeting/meeting-room/meeting-room-teacher.vue similarity index 100% rename from src/views/meeting/meeting-room/meeting-room.vue rename to src/views/meeting/meeting-room/meeting-room-teacher.vue diff --git a/src/views/meeting/meeting-room/useAgoraMeeting.ts b/src/views/meeting/meeting-room/useAgoraMeeting.ts index eb3a1c8..9d1b072 100644 --- a/src/views/meeting/meeting-room/useAgoraMeeting.ts +++ b/src/views/meeting/meeting-room/useAgoraMeeting.ts @@ -22,14 +22,29 @@ import { useRoute } from 'vue-router'; import type { LocalUser, MeetingEventCallbacks, NetworkQualityLevel, OnJoinRoomData } from './types'; import { TokenManager } from './useTokenManager'; +/** + * 声网会议 Hook 配置 + */ +export interface AgoraMeetingOptions { + /** 是否禁用本地音视频轨道(用于监控端等纯观看场景) */ + disableLocalTracks?: boolean; +} + /** * 声网会议核心 Hook * @param {Ref} courseRoomId - 课程房间 ID * @param {Ref} userName - 用户名称 * @param {Ref} shortUidRef - 短 UID 引用(由外部维护) * @param {MeetingEventCallbacks} callbacks - 事件回调函数 + * @param {AgoraMeetingOptions} options - 配置项 */ -export function useAgoraMeeting(courseRoomId: Ref, userName: Ref, shortUidRef: Ref, callbacks?: MeetingEventCallbacks) { +export function useAgoraMeeting( + courseRoomId: Ref, + userName: Ref, + shortUidRef: Ref, + callbacks?: MeetingEventCallbacks, + options?: AgoraMeetingOptions +) { /** 设置声网日志等级 */ AgoraRTC.setLogLevel(2); // 从路由获取设备 ID @@ -81,6 +96,8 @@ export function useAgoraMeeting(courseRoomId: Ref, userName: Ref /** 是否正在共享屏幕 */ const isScreenSharing = ref(false); + /** 是否禁用本地音视频轨道 */ + const disableLocalTracks = Boolean(options?.disableLocalTracks); // 同步本地用户名称 watchEffect(() => { @@ -355,40 +372,45 @@ export function useAgoraMeeting(courseRoomId: Ref, userName: Ref try { FullLoading.show('正在加入会议...'); - // 初始化本地麦克风 - try { - localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({ - microphoneId: microphoneDeviceId.value || undefined, - encoderConfig: { sampleRate: 16000, bitrate: 16 }, - }); - } catch (error) { - console.error('麦克风初始化失败:', error); - const err = error as { code?: string; name?: string; message?: string }; - if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) { - throw new Error('麦克风被占用,请关闭其他使用麦克风的程序后重试', { cause: error }); - } else { - throw new Error('麦克风初始化失败,请检查设备连接', { cause: error }); + if (!disableLocalTracks) { + // 初始化本地麦克风 + try { + localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack({ + microphoneId: microphoneDeviceId.value || undefined, + encoderConfig: { sampleRate: 16000, bitrate: 16 }, + }); + } catch (error) { + console.error('麦克风初始化失败:', error); + const err = error as { code?: string; name?: string; message?: string }; + if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) { + throw new Error('麦克风被占用,请关闭其他使用麦克风的程序后重试', { cause: error }); + } else { + throw new Error('麦克风初始化失败,请检查设备连接', { cause: error }); + } } - } - // 初始化本地摄像头 - try { - localVideoTrack = await AgoraRTC.createCameraVideoTrack({ - cameraId: cameraDeviceId.value || undefined, - encoderConfig: { width: 640, height: 480, frameRate: 15, bitrateMin: 300, bitrateMax: 600 }, - }); - } catch (error) { - console.error('摄像头初始化失败:', error); - const err = error as { code?: string; name?: string; message?: string }; - if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) { - throw new Error('摄像头被占用,请关闭其他使用摄像头的程序后重试', { cause: error }); - } else { - throw new Error('摄像头初始化失败,请检查设备连接', { cause: error }); + // 初始化本地摄像头 + try { + localVideoTrack = await AgoraRTC.createCameraVideoTrack({ + cameraId: cameraDeviceId.value || undefined, + encoderConfig: { width: 640, height: 480, frameRate: 15, bitrateMin: 300, bitrateMax: 600 }, + }); + } catch (error) { + console.error('摄像头初始化失败:', error); + const err = error as { code?: string; name?: string; message?: string }; + if (err.code === 'NOT_READABLE' || err.name === 'NotReadableError' || err.message?.includes('Device in use')) { + throw new Error('摄像头被占用,请关闭其他使用摄像头的程序后重试', { cause: error }); + } else { + throw new Error('摄像头初始化失败,请检查设备连接', { cause: error }); + } } - } - localUser.videoTrack = localVideoTrack; - localUser.audioTrack = localAudioTrack; + localUser.videoTrack = localVideoTrack; + localUser.audioTrack = localAudioTrack; + } else { + localUser.videoTrack = null; + localUser.audioTrack = null; + } // join 返回声网实际分配的 uid(传 0 时声网会自动分配) // 更新本地用户 UID @@ -402,43 +424,45 @@ export function useAgoraMeeting(courseRoomId: Ref, userName: Ref tokenManagerRef.value.setCurrentToken(roomData.tokenInfo); } - // ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个) - const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = []; + if (!disableLocalTracks) { + // ✅ 根据恢复的状态决定是否发布轨道(如果被禁麦/禁视频,只发布另一个) + const tracksToPublish: (ICameraVideoTrack | IMicrophoneAudioTrack)[] = []; - // 如果没有被禁麦,发布音频 - if (!roomData?.isAudioMuted && localAudioTrack) { - tracksToPublish.push(localAudioTrack); - } + // 如果没有被禁麦,发布音频 + if (!roomData?.isAudioMuted && localAudioTrack) { + tracksToPublish.push(localAudioTrack); + } - // 如果没有被禁视频,发布视频 - if (!roomData?.isVideoMuted && localVideoTrack) { - tracksToPublish.push(localVideoTrack); - } + // 如果没有被禁视频,发布视频 + if (!roomData?.isVideoMuted && localVideoTrack) { + tracksToPublish.push(localVideoTrack); + } - console.log('[Agora] 准备发布的轨道:', { - audio: !roomData?.isAudioMuted, - video: !roomData?.isVideoMuted, - tracksCount: tracksToPublish.length, - }); + console.log('[Agora] 准备发布的轨道:', { + audio: !roomData?.isAudioMuted, + video: !roomData?.isVideoMuted, + tracksCount: tracksToPublish.length, + }); - if (tracksToPublish.length > 0) { - await client.value?.publish(tracksToPublish); - console.log('[Agora] 轨道已发布,准备播放视频'); - } else { - console.log('[Agora] 用户被禁麦/禁视频,暂不发布任何轨道'); - } - - // 加入并发布成功后再播放本地视频(确保 player-local 元素已存在) - if (localVideoTrack) { - // 使用 nextTick 等待 Vue 响应式更新完成 - await nextTick(); - const el = document.getElementById('player-local'); - console.log('[Agora] 查找 player-local 元素:', el ? '找到' : '未找到'); - if (el && !el.querySelector('video')) { - localVideoTrack?.play(el); - console.log('[Agora] 本地视频已播放'); + if (tracksToPublish.length > 0) { + await client.value?.publish(tracksToPublish); + console.log('[Agora] 轨道已发布,准备播放视频'); } else { - console.warn('[Agora] 未找到 player-local 元素或已有视频', el ? '元素存在' : '元素不存在'); + console.log('[Agora] 用户被禁麦/禁视频,暂不发布任何轨道'); + } + + // 加入并发布成功后再播放本地视频(确保 player-local 元素已存在) + if (localVideoTrack) { + // 使用 nextTick 等待 Vue 响应式更新完成 + await nextTick(); + const el = document.getElementById('player-local'); + console.log('[Agora] 查找 player-local 元素:', el ? '找到' : '未找到'); + if (el && !el.querySelector('video')) { + localVideoTrack?.play(el); + console.log('[Agora] 本地视频已播放'); + } else { + console.warn('[Agora] 未找到 player-local 元素或已有视频', el ? '元素存在' : '元素不存在'); + } } } @@ -606,9 +630,9 @@ export function useAgoraMeeting(courseRoomId: Ref, userName: Ref /** * 清理 Agora 客户端资源 - * @param options.toastSuccess - 是否显示成功提示 + * @param teardownOptions.toastSuccess - 是否显示成功提示 */ - async function teardownAgoraClient(options: { toastSuccess?: boolean }): Promise { + async function teardownAgoraClient(teardownOptions: { toastSuccess?: boolean }): Promise { cleanupTokenManager(); const currentClient = client.value; client.value = null; @@ -647,7 +671,7 @@ export function useAgoraMeeting(courseRoomId: Ref, userName: Ref localUser.videoTrack = null; localUser.audioTrack = null; - if (options.toastSuccess) { + if (teardownOptions.toastSuccess) { callbacks?.onLeft?.(); } }