feat(websocket): 优化投屏状态管理并新增全员禁麦功能

- 重构 Redis 投屏状态存储,统一使用 screenShareOwnerUid 字段
- 移除冗余的 screenShareUid 字段,简化房间状态数据结构
- 新增 client_mute_all_students 消息类型支持全员禁麦功能
- 实现创建者权限验证的全员禁麦逻辑,排除老师角色用户
- 优化 Tauri 桌面应用悬浮窗创建接口,支持状态参数传递
- 添加悬浮窗关闭功能及教师端静音状态同步机制
- 修复学生端界面中投屏状态变量命名不一致问题
- 统一按钮样式规范,提升界面组件一致性
This commit is contained in:
2026-03-15 18:21:15 +08:00
parent cddb2d65a4
commit bfaad97f61
17 changed files with 610 additions and 299 deletions

View File

@ -247,19 +247,14 @@ export class MeetingRedisService {
/**
* 设置投屏状态
* @param roomId - 房间 ID
* @param screenShareUid - 投屏人短 UIDnull 表示停止投屏
* @param screenShareOwnerUid - 投屏人短 UIDnull 表示停止投屏
*/
async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise<void> {
async setScreenSharing(roomId: string, screenShareOwnerUid: number | null): Promise<void> {
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<string, string> = { 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 } : {}),

View File

@ -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<ClientToServerMessageType>('client_mute_all_students')
public async handleMuteAllStudents(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
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<ClientToServerMessageType>('client_unmute_audio')
public async handleUnmuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
@ -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}`);

View File

@ -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';
/** 投屏状态:正在投屏的用户短 UIDundefined 表示无人投屏 */
screenShareUid?: number;
/** 投屏状态:正在投屏的用户短 UIDundefined表示无人投屏 */
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' // 设置主屏用户