From bfaad97f613e14ec8b9c639a2a126cfb10656fec 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: Sun, 15 Mar 2026 18:21:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(websocket):=20=E4=BC=98=E5=8C=96=E6=8A=95?= =?UTF-8?q?=E5=B1=8F=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E5=B9=B6=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=85=A8=E5=91=98=E7=A6=81=E9=BA=A6=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 Redis 投屏状态存储,统一使用 screenShareOwnerUid 字段 - 移除冗余的 screenShareUid 字段,简化房间状态数据结构 - 新增 client_mute_all_students 消息类型支持全员禁麦功能 - 实现创建者权限验证的全员禁麦逻辑,排除老师角色用户 - 优化 Tauri 桌面应用悬浮窗创建接口,支持状态参数传递 - 添加悬浮窗关闭功能及教师端静音状态同步机制 - 修复学生端界面中投屏状态变量命名不一致问题 - 统一按钮样式规范,提升界面组件一致性 --- .../websocket/meeting-redis.service.ts | 16 +- .../modules/websocket/meeting.websocket.ts | 48 ++- node_api/src/modules/websocket/types.ts | 10 +- src-tauri/src/invoke/handlers.rs | 1 + src-tauri/src/window/commands.rs | 15 +- .../src/window/create_float_list_window.rs | 25 +- .../app/app-head-bar/app-head-bar.vue | 35 +- .../page-cut-ui-style/page-cut-ui-style.vue | 19 +- src/main.ts | 1 + .../aa-components/floating-list.vue | 51 ++- .../floating-list-window.vue | 3 +- src/styles/main/button.scss | 337 ++++++++++++++++++ src/styles/main/dom.scss | 29 +- src/utils/tauri/windows-action.ts | 15 +- .../meeting-room/meeting-room-student.vue | 86 +---- .../meeting-room/meeting-room-teacher.vue | 213 +++++------ src/views/meeting/meeting-room/types.ts | 5 +- 17 files changed, 610 insertions(+), 299 deletions(-) create mode 100644 src/styles/main/button.scss diff --git a/node_api/src/modules/websocket/meeting-redis.service.ts b/node_api/src/modules/websocket/meeting-redis.service.ts index 2eb2eed..fb00a0a 100644 --- a/node_api/src/modules/websocket/meeting-redis.service.ts +++ b/node_api/src/modules/websocket/meeting-redis.service.ts @@ -247,19 +247,14 @@ export class MeetingRedisService { /** * 设置投屏状态 * @param roomId - 房间 ID - * @param screenShareUid - 投屏人短 UID,null 表示停止投屏 + * @param screenShareOwnerUid - 投屏人短 UID,null 表示停止投屏 */ - async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise { + async setScreenSharing(roomId: string, screenShareOwnerUid: number | null): Promise { const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`; - if (screenShareUid === null) { - await this.getClient().hdel(key, 'screenShareUid'); + if (screenShareOwnerUid === null) { await this.getClient().hdel(key, 'screenShareOwnerUid'); } else { - const hm: Record = { screenShareUid: String(screenShareUid) }; - if (typeof screenShareOwnerUid === 'number' && Number.isFinite(screenShareOwnerUid) && screenShareOwnerUid > 0) { - hm.screenShareOwnerUid = String(screenShareOwnerUid); - } - await this.getClient().hset(key, hm); + await this.getClient().hset(key, { screenShareOwnerUid: String(screenShareOwnerUid) }); await this.getClient().expire(key, 24 * 60 * 60); } } @@ -297,7 +292,6 @@ export class MeetingRedisService { async getRoomState(roomId: string): Promise<{ classStatus: 'finished' | 'in_class' | 'not_started'; speakerUid?: number; - screenShareUid?: number; screenShareOwnerUid?: number; teacherUid?: number; endTimestamp?: number; @@ -306,14 +300,12 @@ export class MeetingRedisService { const map = await this.getClient().hgetall(key); const status = (map?.classStatus as any) || 'not_started'; const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined; - const screenShareUid = map?.screenShareUid ? Number(map.screenShareUid) : undefined; const screenShareOwnerUid = map?.screenShareOwnerUid ? Number(map.screenShareOwnerUid) : undefined; const teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined; const endTimestamp = map?.endTimestamp ? Number(map.endTimestamp) : undefined; return { classStatus: status, ...(Number.isFinite(speakerUid) ? { speakerUid } : {}), - ...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}), ...(Number.isFinite(screenShareOwnerUid) ? { screenShareOwnerUid } : {}), ...(Number.isFinite(teacherUid) ? { teacherUid } : {}), ...(Number.isFinite(endTimestamp) ? { endTimestamp } : {}), diff --git a/node_api/src/modules/websocket/meeting.websocket.ts b/node_api/src/modules/websocket/meeting.websocket.ts index 11901cc..0a7368c 100644 --- a/node_api/src/modules/websocket/meeting.websocket.ts +++ b/node_api/src/modules/websocket/meeting.websocket.ts @@ -250,7 +250,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi const shortUid = Number(socket.data.user?.shortUid || 0); const userName = socket.data.user?.userName || data.userName || '用户'; const isHost = socket.data.user?.role !== 0; - + const joinMode: JoinMode = data?.joinMode === 'classroom' || data?.joinMode === 'monitor' || data?.joinMode === 'student' ? data.joinMode : isHost ? 'classroom' : 'student'; @@ -363,7 +363,6 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi isAudioMuted: userState.isAudioMuted, isVideoMuted: userState.isVideoMuted, classStatus: roomState.classStatus, - screenShareUid: roomState.screenShareUid, screenShareOwnerUid: roomState.screenShareOwnerUid, speakerUid, teacherUid, @@ -501,6 +500,44 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi this.log(`用户 ${data.targetUid} 已被禁麦,房间 ${data.roomId}`); } + @SubscribeMessage('client_mute_all_students') + public async handleMuteAllStudents(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise { + // 基础参数校验 + if (!data?.roomId) { + this.logger.warn({}, '[会议] mute_all_students 数据不完整', 'MeetingWebSocket'); + return; + } + + // 权限校验:仅创建者/管理员可全员禁麦 + const isHost = socket.data.user?.role !== 0; + if (!isHost) { + this.logger.warn({}, `[会议] 非创建者尝试全员禁麦:userId=${socket.data.user?.userId}`, 'MeetingWebSocket'); + return; + } + + // 获取房间内所有用户的 socketIds + const sockets = await this.server?.in(data.roomId).fetchSockets(); + if (!sockets || sockets.length === 0) { + return; + } + + // 遍历所有用户,排除老师(创建者),并行执行所有操作 + const promises = sockets.map(async (s) => { + const targetUid = s.data.user?.shortUid; + // 排除老师自己(role === 0 表示学生) + if (s.data.user?.role === 0 && Number.isFinite(targetUid)) { + // 下发禁麦通知 + this.server?.to(s.id).emit('message', { type: 'sev_mute_audio', data: { fromRoomId: data.roomId, targetUid: targetUid! } }); + // 写入 Redis:下次加入/重连时恢复禁麦状态 + return this.redisService.setUserState(data.roomId, targetUid!, { isAudioMuted: true }).catch(() => {}); + } + return Promise.resolve(); + }); + + await Promise.all(promises); + this.log(`全员禁麦成功:roomId=${data.roomId}`); + } + @SubscribeMessage('client_unmute_audio') public async handleUnmuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise { // 基础参数校验 @@ -679,7 +716,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi return; } // 保存投屏状态到 Redis - await this.redisService.setScreenSharing(data.roomId, screenUid, targetUid); + await this.redisService.setScreenSharing(data.roomId, targetUid); // 广播给整个房间:有人开始投屏 this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } }); this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`); @@ -733,7 +770,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi if (monitorSocketIds.length > 0) { // 发送给所有监控端的 socket - ns.to(monitorSocketIds).emit('message', { type: 'sev_student_order_changed', data: { fromRoomId: data.roomId, studentShortIds: data.studentShortIds } }); + ns.to(monitorSocketIds).emit('message', { + type: 'sev_student_order_changed', + data: { fromRoomId: data.roomId, studentShortIds: data.studentShortIds }, + }); } } this.log(`创建者更新了学生排序:roomId=${data.roomId}, 学生数量=${data.studentShortIds.length}`); diff --git a/node_api/src/modules/websocket/types.ts b/node_api/src/modules/websocket/types.ts index 3ec001c..8316ef3 100644 --- a/node_api/src/modules/websocket/types.ts +++ b/node_api/src/modules/websocket/types.ts @@ -113,6 +113,8 @@ export interface MeetingClientToServerEvents { client_mute_audio: (data: MeetingMuteUserData) => void; /** 创建者解除禁麦 */ client_unmute_audio: (data: MeetingMuteUserData) => void; + /** 创建者全员禁麦(不包括老师) */ + client_mute_all_students: (data: { roomId: string }) => void; /** 创建者禁视频 */ client_mute_video: (data: MeetingMuteUserData) => void; /** 创建者解除禁视频 */ @@ -159,12 +161,11 @@ export interface MeetingJoinRoomSuccessData { isVideoMuted: boolean; /** 课堂状态:not_started(未开始)/ in_class(上课中)/ finished(已下课) */ classStatus: 'finished' | 'in_class' | 'not_started'; - /** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */ - screenShareUid?: number; + /** 投屏状态:正在投屏的用户短 UID,undefined表示无人投屏 */ screenShareOwnerUid?: number; - /** 上台的短uid,默认为老师的uid */ + /** 上台的短 uid,默认为老师的 uid */ speakerUid: number; - /** 老师的短uid */ + /** 老师的短 uid */ teacherUid: number; /** 房间结束时间(毫秒时间戳) */ endTimestamp?: number; @@ -268,6 +269,7 @@ export type ClientToServerMessageType = | 'client_join_room' // 加入会议房间 | 'client_kick_user' // 踢出指定用户(仅创建者) | 'client_leave_room' // 离开会议房间 + | 'client_mute_all_students' // 全员禁麦(不包括老师,仅创建者) | 'client_mute_audio' // 禁麦指定用户(仅创建者) | 'client_mute_video' // 禁视频指定用户(仅创建者) | 'client_set_main_video' // 设置主屏用户 diff --git a/src-tauri/src/invoke/handlers.rs b/src-tauri/src/invoke/handlers.rs index d6b5b2f..9f6abdf 100644 --- a/src-tauri/src/invoke/handlers.rs +++ b/src-tauri/src/invoke/handlers.rs @@ -37,6 +37,7 @@ pub fn register_commands(builder: Builder) -> Builder { stop_prompt_tone, // 窗口相关命令 create_float_list_window, + close_float_list_window, create_popup_window, create_main_window, // 系统托盘相关命令 diff --git a/src-tauri/src/window/commands.rs b/src-tauri/src/window/commands.rs index b99f3c6..a19093d 100644 --- a/src-tauri/src/window/commands.rs +++ b/src-tauri/src/window/commands.rs @@ -1,4 +1,4 @@ -use crate::window::create_float_list_window::create_float_list_window_impl; +use crate::window::create_float_list_window::{close_float_list_window_impl, create_float_list_window_impl}; use crate::window::create_main_window::create_main_window_impl; use crate::window::create_popup_window::create_popup_window_impl; use crate::window::derive::TargetLocation; @@ -6,8 +6,17 @@ use tauri::{AppHandle, Window}; /// 创建悬浮列表窗口 #[tauri::command] -pub async fn create_float_list_window(app: AppHandle) -> Result { - create_float_list_window_impl(std::sync::Arc::new(app)).await.map(|_| "success".to_string()) +pub async fn create_float_list_window(app: AppHandle, is_teacher_muted: bool) -> Result { + create_float_list_window_impl(std::sync::Arc::new(app), is_teacher_muted) + .await + .map(|_| "success".to_string()) +} + +/// 关闭悬浮列表窗口 +#[tauri::command] +pub fn close_float_list_window(app: AppHandle) -> Result { + close_float_list_window_impl(std::sync::Arc::new(app)) + .map(|_| "success".to_string()) } /// 创建弹出框窗口 diff --git a/src-tauri/src/window/create_float_list_window.rs b/src-tauri/src/window/create_float_list_window.rs index 1319410..2026641 100644 --- a/src-tauri/src/window/create_float_list_window.rs +++ b/src-tauri/src/window/create_float_list_window.rs @@ -11,20 +11,21 @@ use crate::{ use tauri::{AppHandle, LogicalPosition, LogicalSize, Runtime, WebviewUrl, WebviewWindowBuilder, utils::config::BackgroundThrottlingPolicy}; /// 创建悬浮列表窗口(内部实现) -pub async fn create_float_list_window_impl(app: Arc>) -> Result<(), String> { +pub async fn create_float_list_window_impl(app: Arc>, is_teacher_muted: bool) -> Result<(), String> { if show_windows_by_label(app.as_ref(), WindowLabel::FLOAT_LIST) { return Ok(()); } // 悬浮列表尺寸 let float_info = calculate_float_info_sync(); - // 获取屏幕尺寸,优先为main窗口所在屏幕,主窗口没有打开时,才找主屏幕 + // 获取屏幕尺寸,优先为 main 窗口所在屏幕,主窗口没有打开时,才找主屏幕 let monitor = get_monitor_for_window(app.as_ref())?; // 获取屏幕缩放因子 let scale_factor = monitor.scale_factor(); let page_size: LogicalSize = monitor.work_area().size.to_logical(scale_factor); let screen_width = page_size.width; let screen_height = page_size.height; - let url = format!("floating-list-window.html"); + // 构建 URL 时附加状态参数 + let url = format!("floating-list-window.html?isTeacherMuted={}", is_teacher_muted); let label = WindowLabel::FLOAT_LIST.as_ref(); // 创建悬浮球窗口,直接在初始化时设置好位置和尺寸 @@ -64,7 +65,7 @@ pub async fn create_float_list_window_impl(app: Arc>) - let _ = window.set_size(LogicalSize::new(float_info.width as f64, float_info.height as f64)); } - // 窗口显示后等待200毫秒再执行动画 + // 窗口显示后等待 200 毫秒再执行动画 tokio::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; let _ = animate_to_bottom_right(window, screen_width as i32, screen_height as i32).await; @@ -72,6 +73,22 @@ pub async fn create_float_list_window_impl(app: Arc>) - Ok(()) } +/// 关闭悬浮列表窗口(内部实现) +pub fn close_float_list_window_impl(app: Arc>) -> Result<(), String> { + use tauri::Manager; + + // 尝试获取悬浮列表窗口 + if let Some(window) = app.get_webview_window(WindowLabel::FLOAT_LIST.as_ref()) { + // 关闭窗口 + window.close().map_err(|e| format!("关闭悬浮列表窗口失败:{}", e))?; + log::info!("已成功关闭悬浮列表窗口"); + } else { + log::warn!("悬浮列表窗口不存在,无需关闭"); + } + + Ok(()) +} + /// 根据用户登录状态和课程选择情况动态计算弹出窗口高度 pub fn calculate_float_info_sync() -> FloatListInfo { // 默认基础项目数 diff --git a/src/components/app/app-head-bar/app-head-bar.vue b/src/components/app/app-head-bar/app-head-bar.vue index cc52492..eb5b9ed 100644 --- a/src/components/app/app-head-bar/app-head-bar.vue +++ b/src/components/app/app-head-bar/app-head-bar.vue @@ -164,40 +164,7 @@ } .operating-button { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - margin-right: 15px; - cursor: pointer; - background-color: var(--button-bg); - border-radius: 50%; - transition: all 0.2s ease; - - &.close-button { - --button-bg: #f56057; - } - - &.minimum-button { - --button-bg: #fec428; - } - - &.expand-button { - --button-bg: #1fd42a; - } - - &.back-button { - --button-bg: #7c7c7c; - } - - .icon { - display: flex; - align-items: center; - justify-content: center; - width: 12px; - height: 12px; - } + // 使用统一按钮样式,移除重复定义 } } diff --git a/src/components/page/page-cut-ui-style/page-cut-ui-style.vue b/src/components/page/page-cut-ui-style/page-cut-ui-style.vue index 5026972..048bfad 100644 --- a/src/components/page/page-cut-ui-style/page-cut-ui-style.vue +++ b/src/components/page/page-cut-ui-style/page-cut-ui-style.vue @@ -37,23 +37,6 @@ diff --git a/src/main.ts b/src/main.ts index 326c641..0304bf9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import ErrorHandler from '@/utils/error-handler'; import 'dayjs/locale/zh-cn'; import '@/styles/main/root.scss'; import '@/styles/main/dom.scss'; +import '@/styles/main/button.scss'; // 统一按钮样式规范 import '@/styles/main/element-plus.scss'; import '@/styles/main/global.scss'; import '@/styles/main/font.css'; 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 b626688..8aaa614 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 @@ -10,7 +10,7 @@ - 全员静音 + 全员静音