From 3ac83f1b942a0899ea25bd2817b1f3dbe72a8931 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 17:45:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(meeting):=20=E6=94=AF=E6=8C=81=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E7=BB=93=E6=9D=9F=E6=97=B6=E9=97=B4=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8F=8A=E6=8A=95=E5=B1=8F=E5=8F=8C=E6=B5=81=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增房间结束时间字段,支持老师端设置和续期 - 实现投屏双流模式,老师可同时展示摄像头和屏幕共享 - 学生端支持切换主画面显示摄像头或投屏内容 - 优化屏幕共享实现,使用独立声网客户端避免轨道冲突 - 增加设备冲突检测逻辑,区分教室端、监控端和学生端入口 --- .../websocket/meeting-redis.service.ts | 113 +++++++-- .../modules/websocket/meeting.websocket.ts | 146 ++++++++++-- node_api/src/modules/websocket/types.ts | 57 ++++- src/views/meeting/join-room/join-room.vue | 45 ++-- .../meeting-room/meeting-room-monitor.vue | 61 ++++- .../meeting-room/meeting-room-student.vue | 204 ++++++++++++---- .../meeting-room/meeting-room-teacher.vue | 182 ++++++++------ src/views/meeting/meeting-room/types.ts | 53 +++++ .../meeting/meeting-room/useAgoraMeeting.ts | 222 ++++++------------ src/views/meeting/meeting-room/websocket.ts | 19 +- 10 files changed, 768 insertions(+), 334 deletions(-) diff --git a/node_api/src/modules/websocket/meeting-redis.service.ts b/node_api/src/modules/websocket/meeting-redis.service.ts index 572cff9..0a9b0a0 100644 --- a/node_api/src/modules/websocket/meeting-redis.service.ts +++ b/node_api/src/modules/websocket/meeting-redis.service.ts @@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common'; import { RedisService } from '../../plugins/redis/redis.service'; import type { Redis } from 'ioredis'; -import type { BlacklistUser, UserPermissionState } from './types'; +import type { BlacklistUser, JoinMode, UserPermissionState } from './types'; @Injectable() export class MeetingRedisService { @@ -63,6 +63,7 @@ export class MeetingRedisService { * - classStatus: 课堂状态(not_started | in_class | finished) * - speakerUid: 当前主讲人短 UID(可选) * - teacherUid: 老师短 UID(可选) + * - endTimestamp: 房间结束时间(毫秒时间戳,可选) * 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中 * 过期时间:24 小时 */ @@ -115,18 +116,32 @@ export class MeetingRedisService { async getUserState(roomId: string, shortUid: number): Promise { const key = this.getUserStateKey(roomId, shortUid); const map = await this.getClient().hgetall(key); + let socketEntries: Array<{ socketId: string; joinMode: JoinMode }> = []; + if (map?.socketEntries) { + try { + socketEntries = JSON.parse(map.socketEntries); + } catch { + socketEntries = []; + } + } + let socketIds: string[] = []; - if (map?.socketIds) { + if (socketEntries.length > 0) { + socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))]; + } else if (map?.socketIds) { try { socketIds = JSON.parse(map.socketIds); + socketEntries = socketIds.map((socketId) => ({ socketId, joinMode: 'classroom' })); } catch { socketIds = []; + socketEntries = []; } } return { isAudioMuted: map?.isAudioMuted === '1', isVideoMuted: map?.isVideoMuted === '1', socketIds, + socketEntries, }; } @@ -223,12 +238,17 @@ export class MeetingRedisService { * @param roomId - 房间 ID * @param screenShareUid - 投屏人短 UID,null 表示停止投屏 */ - async setScreenSharing(roomId: string, screenShareUid: number | null): Promise { + async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise { const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`; if (screenShareUid === null) { await this.getClient().hdel(key, 'screenShareUid'); + await this.getClient().hdel(key, 'screenShareOwnerUid'); } else { - await this.getClient().hset(key, { screenShareUid: String(screenShareUid) }); + 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().expire(key, 24 * 60 * 60); } } @@ -267,22 +287,67 @@ export class MeetingRedisService { classStatus: 'finished' | 'in_class' | 'not_started'; speakerUid?: number; screenShareUid?: number; + screenShareOwnerUid?: number; teacherUid?: number; + endTimestamp?: number; }> { const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`; 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 } : {}), }; } + /** + * 设置房间结束时间 + * @param roomId - 房间 ID + * @param endTimestamp - 房间结束时间(毫秒时间戳) + * @param mode - 写入模式:max 表示只允许延长,不允许缩短 + */ + async setRoomEndTimestamp(roomId: string, endTimestamp: number, mode: 'max' | 'overwrite' = 'max'): Promise { + const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`; + const next = Number(endTimestamp); + if (!Number.isFinite(next) || next <= 0) { + return; + } + + if (mode === 'max') { + const current = await this.getClient().hget(key, 'endTimestamp'); + const currentNum = current ? Number(current) : NaN; + if (Number.isFinite(currentNum) && currentNum > 0 && currentNum >= next) { + return; + } + } + + await this.getClient().hset(key, { endTimestamp: String(next) }); + await this.getClient().expire(key, 24 * 60 * 60); + } + + /** + * 获取房间结束时间 + * @param roomId - 房间 ID + */ + async getRoomEndTimestamp(roomId: string): Promise { + const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`; + const v = await this.getClient().hget(key, 'endTimestamp'); + const t = v ? Number(v) : NaN; + if (!Number.isFinite(t) || t <= 0) { + return null; + } + return t; + } + // ==================== Socket 连接管理 ==================== /** @@ -290,18 +355,18 @@ export class MeetingRedisService { * @param roomId - 房间 ID * @param shortUid - 短 UID * @param socketId - Socket.IO 连接 ID + * @param joinMode - 加入方式(课程/监控/学生) */ - async addSocket(roomId: string, shortUid: number, socketId: string): Promise { + async addSocket(roomId: string, shortUid: number, socketId: string, joinMode: JoinMode): Promise { const key = this.getUserStateKey(roomId, shortUid); - // 获取当前 socketIds const currentState = await this.getUserState(roomId, shortUid); - const socketIds = currentState.socketIds || []; - // 添加新 socketId(如果不存在) - if (!socketIds.includes(socketId)) { - socketIds.push(socketId); + const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : []; + const existed = socketEntries.some((e) => e.socketId === socketId); + if (!existed) { + socketEntries.push({ socketId, joinMode }); } - // 存储到 Redis - await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds) }); + const socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))]; + await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds), socketEntries: JSON.stringify(socketEntries) }); await this.getClient().expire(key, 24 * 60 * 60); } @@ -313,15 +378,19 @@ export class MeetingRedisService { */ async removeSocket(roomId: string, shortUid: number, socketId: string): Promise { const key = this.getUserStateKey(roomId, shortUid); - // 获取当前 socketIds const currentState = await this.getUserState(roomId, shortUid); - const socketIds = currentState.socketIds || []; - // 移除指定的 socketId - const newSocketIds = socketIds.filter((id) => id !== socketId); - if (newSocketIds.length > 0) { - await this.getClient().hset(key, { socketIds: JSON.stringify(newSocketIds) }); + const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : []; + const nextEntries = socketEntries.length > 0 ? socketEntries.filter((e) => e.socketId !== socketId) : []; + const nextSocketIds = + nextEntries.length > 0 ? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))] : (currentState.socketIds || []).filter((id) => id !== socketId); + + if (nextSocketIds.length > 0) { + const hm: Record = { socketIds: JSON.stringify(nextSocketIds) }; + if (socketEntries.length > 0) { + hm.socketEntries = JSON.stringify(nextEntries); + } + await this.getClient().hset(key, hm); } else { - // 如果没有 socketId 了,删除整个 key(用户离开) await this.getClient().del(key); } } @@ -330,10 +399,14 @@ export class MeetingRedisService { * 获取用户的所有 Socket 连接 ID * @param roomId - 房间 ID * @param shortUid - 短 UID + * @param joinMode - 加入方式(可选;用于多端共存的场景) * @returns Socket 连接 ID 数组 */ - async getSocketIds(roomId: string, shortUid: number): Promise { + async getSocketIds(roomId: string, shortUid: number, joinMode?: JoinMode): Promise { const state = await this.getUserState(roomId, shortUid); + if (joinMode && Array.isArray(state.socketEntries) && state.socketEntries.length > 0) { + return state.socketEntries.filter((e) => e.joinMode === joinMode).map((e) => e.socketId); + } return state.socketIds || []; } diff --git a/node_api/src/modules/websocket/meeting.websocket.ts b/node_api/src/modules/websocket/meeting.websocket.ts index 2844f46..17d8883 100644 --- a/node_api/src/modules/websocket/meeting.websocket.ts +++ b/node_api/src/modules/websocket/meeting.websocket.ts @@ -18,6 +18,7 @@ import { LoggerService } from '@/plugins/logger/logger.service'; import type { ClientToServerMessageType, ErrorMessage, + JoinMode, JoinRoomData, KickUserData, MeetingNamespace, @@ -42,6 +43,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi // 注入 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型) public server: MeetingNamespace | null = null; + private readonly roomExpiryTimers = new Map(); + private readonly roomExpiryLastNotifiedMinute = new Map(); + public constructor( // 房间/用户状态:Redis 持久化 private readonly redisService: MeetingRedisService, @@ -92,10 +96,69 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi // 如果房间内没有 socket 连接,则清理 Redis 数据 if (sockets.length === 0) { await this.redisService.clearRoomAll(roomId); + this.clearRoomExpiryTimer(roomId); this.log(`[会议] 房间已空,已清理 Redis 状态:roomId=${roomId}`); } } + private clearRoomExpiryTimer(roomId: string): void { + const timer = this.roomExpiryTimers.get(roomId); + if (timer) { + clearInterval(timer); + } + this.roomExpiryTimers.delete(roomId); + this.roomExpiryLastNotifiedMinute.delete(roomId); + } + + private ensureRoomExpiryTimer(roomId: string): void { + if (this.roomExpiryTimers.has(roomId)) { + return; + } + + const timer = setInterval(async () => { + try { + const endTimestamp = await this.redisService.getRoomEndTimestamp(roomId); + if (!endTimestamp) { + return; + } + + const now = Date.now(); + const remainingMs = endTimestamp - now; + if (remainingMs <= 0) { + return; + } + + if (remainingMs > 5 * 60 * 1000) { + return; + } + + const currentMinute = Math.floor(now / 60000); + const lastMinute = this.roomExpiryLastNotifiedMinute.get(roomId); + if (lastMinute === currentMinute) { + return; + } + this.roomExpiryLastNotifiedMinute.set(roomId, currentMinute); + + const ns = this.server; + if (!ns) { + return; + } + const sockets = await ns.in(roomId).fetchSockets(); + const teacherSockets = sockets.filter((s) => s.data.user?.role !== 0); + for (const s of teacherSockets) { + ns.to(s.id).emit('message', { + type: 'sev_room_will_expire', + data: { fromRoomId: roomId, endTimestamp, remainingMs }, + }); + } + } catch (error) { + this.logger.error({ error }, `[会议] 房间到期提醒定时器执行失败: roomId=${roomId}`, 'MeetingWebSocket'); + } + }, 30 * 1000); + + this.roomExpiryTimers.set(roomId, timer); + } + /** * 初始化后,在 namespace 层添加连接鉴权 middleware * @param server - 注入的 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型) @@ -143,8 +206,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {}); } - // 如果是老师(创建者)主动断开连接,发送下课消息给所有人 - if (roomId && isHost) { + // 如果是老师(创建者)在“教室端”主动断开连接,发送下课消息给所有人 + if (roomId && isHost && socket.data.joinMode === 'classroom') { this.log(`老师(创建者)断开连接,发送下课消息:roomId=${roomId}`); // 设置课堂状态为已结束 await this.redisService.setClassStatus(roomId, 'finished'); @@ -152,6 +215,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi this.server?.to(roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: roomId } }); // 清理 Redis 数据 await this.redisService.clearRoomAll(roomId); + this.clearRoomExpiryTimer(roomId); } if (roomId) { @@ -177,6 +241,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi // 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID) const shortUid = socket.data.user?.shortUid; 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'; if (!shortUid) { this.logger.warn({}, '[会议] 用户未认证,无 shortUid', 'MeetingWebSocket'); @@ -196,7 +263,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi } // 检查该用户是否已在其他设备加入了房间 - const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid); + const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid, joinMode); if (existingSocketIds.length > 0) { // 过滤掉当前 socket 自己的连接(同一设备刷新等情况) const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id); @@ -217,7 +284,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi if (result.isValid && result.sockets) { this.server?.to(result.oldSocketId).emit('message', { type: 'sev_device_conflict', - data: { fromRoomId: roomId, reason: '您已在其他设备进入课程', targetUid: shortUid }, + data: { fromRoomId: roomId, reason: '您已在其他设备进入相同入口', targetUid: shortUid }, } satisfies SucceedMessage); result.sockets.forEach((s) => s.disconnect(true)); validSocketIds.push(result.oldSocketId); @@ -243,9 +310,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi socket.join(roomId); socket.data.roomId = roomId; socket.data.courseRoomId = courseRoomId; + socket.data.joinMode = joinMode; // 注册 socketId 到 Redis(用于踢人、禁麦等控制功能) - await this.redisService.addSocket(roomId, shortUid, socket.id); + await this.redisService.addSocket(roomId, shortUid, socket.id, joinMode); // 添加用户到房间用户列表 await this.redisService.addUserToRoom(roomId, shortUid); @@ -265,6 +333,17 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi await this.redisService.setSpeaker(roomId, speakerUid).catch(() => {}); } + if (isHost && typeof data.endTimestamp === 'number') { + const now = Date.now(); + const nextEndTimestamp = Number(data.endTimestamp); + if (Number.isFinite(nextEndTimestamp) && nextEndTimestamp >= now + 5 * 60 * 1000) { + await this.redisService.setRoomEndTimestamp(roomId, nextEndTimestamp, 'max').catch(() => {}); + } + } + + const resolvedEndTimestamp = await this.redisService.getRoomEndTimestamp(roomId).catch(() => null); + this.ensureRoomExpiryTimer(roomId); + // 下发 join 成功包:包含 roomId/shortUid/tokenInfo/恢复状态/房间状态 socket.emit('message', { type: 'sev_join_room', @@ -276,8 +355,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi isVideoMuted: userState.isVideoMuted, classStatus: roomState.classStatus, screenShareUid: roomState.screenShareUid, + screenShareOwnerUid: roomState.screenShareOwnerUid, speakerUid, teacherUid, + ...(resolvedEndTimestamp ? { endTimestamp: resolvedEndTimestamp } : {}), }, }); @@ -290,6 +371,35 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi } } + @SubscribeMessage('client_renew_room_end_timestamp') + public async handleRenewRoomEndTimestamp( + @MessageBody() data: { roomId: string; endTimestamp: number }, + @ConnectedSocket() socket: MeetingSocket + ): Promise { + if (!data?.roomId || typeof data.endTimestamp !== 'number') { + return; + } + const isHost = socket.data.user?.role !== 0; + if (!isHost) { + return; + } + + const now = Date.now(); + const nextEndTimestamp = Number(data.endTimestamp); + if (!Number.isFinite(nextEndTimestamp) || nextEndTimestamp < now + 5 * 60 * 1000) { + socket.emit('message', { type: 'error', data: { reason: '结束时间不能小于当前时间的5分钟' } } satisfies ErrorMessage); + return; + } + + await this.redisService.setRoomEndTimestamp(data.roomId, nextEndTimestamp, 'max'); + this.ensureRoomExpiryTimer(data.roomId); + this.server?.to(data.roomId).emit('message', { + type: 'sev_room_end_timestamp_updated', + data: { fromRoomId: data.roomId, endTimestamp: nextEndTimestamp }, + }); + this.log(`房间 ${data.roomId} 已续期,新的结束时间: ${nextEndTimestamp}`); + } + @SubscribeMessage('client_leave_room') public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise { // Socket.IO 会自动处理离房逻辑,这里仅记录 @@ -299,16 +409,13 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi if (roomId && Number.isFinite(shortUid) && shortUid > 0) { // 移除 socketId 映射 await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {}); - // 从房间用户列表移除 - await this.redisService.removeUserFromRoom(roomId, shortUid).catch(() => {}); - // 清理用户状态 - await this.redisService.clearUserState(roomId, shortUid).catch(() => {}); } try { roomId && socket.leave(roomId); } catch {} socket.data.roomId = undefined; socket.data.courseRoomId = undefined; + socket.data.joinMode = undefined; if (roomId) { await this.cleanupRoomIfEmpty(roomId); } @@ -548,8 +655,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi * 处理开始投屏消息(仅创建者) */ @SubscribeMessage('client_start_screen_share') - public async handleStartScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise { - if (!data?.roomId) { + public async handleStartScreenShare(@MessageBody() data: { roomId: string; screenUid: number }, @ConnectedSocket() socket: MeetingSocket): Promise { + if (!data?.roomId || typeof data.screenUid !== 'number') { return; } const isHost = socket.data.user?.role !== 0; @@ -558,18 +665,22 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi return; } const targetUid = socket.data.user?.shortUid ?? 0; + const screenUid = Number(data.screenUid); + if (!Number.isFinite(screenUid) || screenUid <= 0) { + return; + } // 保存投屏状态到 Redis - await this.redisService.setScreenSharing(data.roomId, targetUid); + await this.redisService.setScreenSharing(data.roomId, screenUid, targetUid); // 广播给整个房间:有人开始投屏 - this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid } }); - this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${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}`); } /** * 处理停止投屏消息(仅创建者) */ @SubscribeMessage('client_stop_screen_share') - public async handleStopScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise { + public async handleStopScreenShare(@MessageBody() data: { roomId: string; screenUid?: number }, @ConnectedSocket() socket: MeetingSocket): Promise { if (!data?.roomId) { return; } @@ -579,10 +690,11 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi return; } const targetUid = socket.data.user?.shortUid ?? 0; + const screenUid = typeof data.screenUid === 'number' ? Number(data.screenUid) : 0; // 清除投屏状态 await this.redisService.setScreenSharing(data.roomId, null); // 广播给整个房间:有人停止投屏 - this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid } }); - this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}`); + this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } }); + this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`); } } diff --git a/node_api/src/modules/websocket/types.ts b/node_api/src/modules/websocket/types.ts index 17fed93..11247e1 100644 --- a/node_api/src/modules/websocket/types.ts +++ b/node_api/src/modules/websocket/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/sort-type-constituents */ /** * meeting WebSocket 模块的“类型总出口” * @@ -41,6 +42,13 @@ export interface MeetingSocketData { * - 主要用于清理 courseRoomId -> roomId 的映射 key */ courseRoomId?: string; + + /** + * 加入方式(课程/监控/学生) + * - 由 handleJoinRoom 写入 + * - 用于区分教师端窗口断开时是否需要触发下课逻辑 + */ + joinMode?: JoinMode; } export interface MeetingJoinRoomData { @@ -79,6 +87,7 @@ export interface MeetingMuteUserData { export interface MeetingScreenShareData { /** 房间 ID(服务端实际 Socket.IO 房间名) */ roomId: string; + screenUid: number; } /** @@ -87,8 +96,10 @@ export interface MeetingScreenShareData { export interface MeetingScreenShareNotifyData { /** 来源房间 */ fromRoomId: string; - /** 投屏用户短 UID */ + /** 目标用户声网 shortUid */ targetUid: number; + /** 投屏用户声网 shortUid */ + screenUid: number; } export interface MeetingClientToServerEvents { @@ -112,6 +123,8 @@ export interface MeetingClientToServerEvents { client_start_screen_share: (data: MeetingScreenShareData) => void; /** 创建者停止投屏 */ client_stop_screen_share: (data: MeetingScreenShareData) => void; + /** 续期房间结束时间(仅创建者/老师) */ + client_renew_room_end_timestamp: (data: { roomId: string; endTimestamp: number }) => void; } export interface MeetingWsMessagePacket { @@ -148,10 +161,13 @@ export interface MeetingJoinRoomSuccessData { classStatus: 'finished' | 'in_class' | 'not_started'; /** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */ screenShareUid?: number; + screenShareOwnerUid?: number; /** 上台的短uid,默认为老师的uid */ speakerUid: number; /** 老师的短uid */ teacherUid: number; + /** 房间结束时间(毫秒时间戳) */ + endTimestamp?: number; } export interface MeetingKickedData { @@ -182,9 +198,25 @@ export interface MeetingClassStateData { fromRoomId: string; } +export interface MeetingRoomWillExpireData { + /** 来源房间 */ + fromRoomId: string; + /** 房间结束时间(毫秒时间戳) */ + endTimestamp: number; + /** 剩余毫秒数 */ + remainingMs: number; +} + +export interface MeetingRoomEndTimestampUpdatedData { + /** 来源房间 */ + fromRoomId: string; + /** 房间结束时间(毫秒时间戳) */ + endTimestamp: number; +} + export type MeetingDownlinkPacket = /** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */ - | MeetingWsMessagePacket<'error', { reason: string; code?: number }> + | ErrorMessage /** 下课通知 */ | MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData> /** 上课通知 */ @@ -208,7 +240,11 @@ export type MeetingDownlinkPacket = /** 解除禁麦通知 */ | MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData> /** 解除禁视频通知 */ - | MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>; + | MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData> + /** 房间即将到期提醒(仅老师端) */ + | MeetingWsMessagePacket<'sev_room_will_expire', MeetingRoomWillExpireData> + /** 房间结束时间更新(续期后广播) */ + | MeetingWsMessagePacket<'sev_room_end_timestamp_updated', MeetingRoomEndTimestampUpdatedData>; export interface MeetingServerToClientEvents { /** 约定:服务端统一通过 message 事件下发业务包(packet.type 决定语义) */ @@ -230,7 +266,8 @@ export type ClientToServerMessageType = | 'client_start_screen_share' // 开始投屏 | 'client_stop_screen_share' // 停止投屏 | 'client_unmute_audio' // 解除禁麦(仅创建者) - | 'client_unmute_video'; // 解除禁视频(仅创建者) + | 'client_unmute_video' // 解除禁视频(仅创建者) + | 'client_renew_room_end_timestamp'; // 续期房间结束时间(仅创建者) /** * 服务端 -> 客户端(下发消息) @@ -247,7 +284,9 @@ export type ServerToClientMessageType = | 'sev_start_screen_share' // 开始投屏通知(广播给所有人) | 'sev_stop_screen_share' // 停止投屏通知(广播给所有人) | 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户) - | 'sev_unmute_video'; // 解除禁视频通知(转发给目标用户) + | 'sev_unmute_video' // 解除禁视频通知(转发给目标用户) + | 'sev_room_will_expire' // 房间即将到期提醒(仅老师端) + | 'sev_room_end_timestamp_updated'; // 房间结束时间更新(续期后广播) export interface ErrorMessage { // 固定为 error,便于前端统一处理 @@ -270,6 +309,8 @@ export interface SucceedMessage { /** * 加入房间数据 */ +export type JoinMode = 'classroom' | 'monitor' | 'student'; + export interface JoinRoomData { // 课程房间 ID(前端传入) courseRoomId: string; @@ -277,6 +318,10 @@ export interface JoinRoomData { homeworkId: number; // 用户名(仅用于日志/展示) userName: string; + /** 加入方式(课程/监控/学生) */ + joinMode: JoinMode; + /** 房间结束时间(毫秒时间戳),仅老师端首次进入时会写入 */ + endTimestamp?: number; } /** @@ -306,6 +351,8 @@ export interface UserPermissionState { isVideoMuted: boolean; /** 用户的所有 Socket 连接 ID(支持多设备同时在线) */ socketIds: string[]; + /** 用户的 Socket 连接详情(用于区分加入方式) */ + socketEntries?: Array<{ socketId: string; joinMode: JoinMode }>; } /** diff --git a/src/views/meeting/join-room/join-room.vue b/src/views/meeting/join-room/join-room.vue index bd7b228..278a801 100644 --- a/src/views/meeting/join-room/join-room.vue +++ b/src/views/meeting/join-room/join-room.vue @@ -103,14 +103,6 @@ - - - - @@ -158,12 +150,22 @@ /** 表单引用 */ const formRef = ref(); - + /** 时间信息 */ + const dateInfo = computed(() => { + const startTimeInfo = getDateObj(meetingStore.currCourseRoom?.startTime || Date.now()); + const endTimeInfo = getDateObj(meetingStore.currCourseRoom?.endTime || Date.now()); + return { + showStartTime: `${startTimeInfo.month}-${startTimeInfo.day} ${startTimeInfo.hour}:${startTimeInfo.minute}`, + showEndTime: `${endTimeInfo.month}-${endTimeInfo.day} ${endTimeInfo.hour}:${endTimeInfo.minute}`, + endTimestamp: endTimeInfo.timestamp, + }; + }); /** 表单数据 */ const formData = ref({ selectedCamera: useStorage('meeting_selected_camera', ''), selectedMicrophone: useStorage('meeting_selected_microphone', ''), - meetingDuration: 40, + // eslint-disable-next-line vue/no-ref-object-reactivity-loss + endTimestamp: dateInfo.value.endTimestamp, }); /** 表单验证规则 */ @@ -182,17 +184,6 @@ /** 课程房间教师 */ const courseTeacher = computed(() => meetingStore.currCourseRoom?.teacher || ''); - /** 时间信息 */ - const dateInfo = computed(() => { - const startTimeInfo = getDateObj(meetingStore.currCourseRoom?.startTime || Date.now()); - const endTimeInfo = getDateObj(meetingStore.currCourseRoom?.endTime || Date.now()); - return { - showStartTime: `${startTimeInfo.month}-${startTimeInfo.day} ${startTimeInfo.hour}:${startTimeInfo.minute}`, - showEndTime: `${endTimeInfo.month}-${endTimeInfo.day} ${endTimeInfo.hour}:${endTimeInfo.minute}`, - timeDiff: endTimeInfo.timestamp - startTimeInfo.timestamp, - }; - }); - /** 用户名称 */ const userName = computed(() => userStore.userInfo?.name || ''); @@ -220,7 +211,7 @@ onMounted(async () => { // 页面加载时就初始化本地轨道,让用户能看到预览 try { - formData.value.meetingDuration = Math.round(dateInfo.value.timeDiff / 1000 / 60); + formData.value.endTimestamp = dateInfo.value.endTimestamp; await getDevices(); await initLocalTracks(); } catch (error: any) { @@ -432,6 +423,13 @@ // 触发表单验证 console.log('即将跳转到 meeting-room 页面'); + const endTimestamp = Number(formData.value.endTimestamp); + const now = Date.now(); + if (!Number.isFinite(endTimestamp) || endTimestamp < now + 5 * 60 * 1000) { + Toast.error('结束时间不能小于当前时间的5分钟'); + return; + } + const joinMode = role.value === 'student' ? 'student' : target === 'monitor' ? 'monitor' : 'classroom'; const targetPath = role.value === 'student' ? '/meeting/meeting-room-student' : target === 'monitor' ? '/meeting/meeting-room-monitor' : '/meeting/meeting-room'; router.push({ @@ -442,7 +440,8 @@ role: role.value, cameraDeviceId: formData.value.selectedCamera, microphoneDeviceId: formData.value.selectedMicrophone, - meetingDuration: formData.value.meetingDuration, + endTimestamp, + joinMode, }, }); } diff --git a/src/views/meeting/meeting-room/meeting-room-monitor.vue b/src/views/meeting/meeting-room/meeting-room-monitor.vue index 9a5bfd5..fc22aec 100644 --- a/src/views/meeting/meeting-room/meeting-room-monitor.vue +++ b/src/views/meeting/meeting-room/meeting-room-monitor.vue @@ -43,9 +43,9 @@ import type { IAgoraRTCRemoteUser } from 'agora-rtc-sdk-ng'; import IconMdiWifi from '~icons/mdi/wifi'; import IconMdiVideo from '~icons/mdi/video'; - import { computed, onMounted, onUnmounted, ref } from 'vue'; + import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'; import { useRoute, useRouter } from 'vue-router'; - import { ElMessage as Toast } from 'element-plus'; + import { ElMessageBox, ElMessage as Toast } from 'element-plus'; import type { OnJoinRoomData, ServerToClientEvents } from './types'; import { useAgoraMeeting } from './useAgoraMeeting'; import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket'; @@ -104,8 +104,26 @@ { disableLocalTracks } ); + /** 加入方式 */ + const joinMode = computed(() => 'monitor' as const); + /** 房间结束时间(毫秒时间戳) */ + const endTimestamp = computed(() => { + const t = Number(route.query.endTimestamp || 0); + return Number.isFinite(t) && t > 0 ? t : null; + }); + /** 房间结束时间(毫秒时间戳) */ + const roomEndTimestamp = ref(null); + /** 是否正在弹出续期窗口 */ + const isRenewPrompting = ref(false); + + watchEffect(() => { + if (roomEndTimestamp.value === null && endTimestamp.value) { + roomEndTimestamp.value = endTimestamp.value; + } + }); + const socket = useWebSocket(); - const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId); + const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp); const { networkQuality, selectedUserUid, leaveChannel, cleanup } = meeting; @@ -192,6 +210,43 @@ if (typeof data?.teacherUid === 'number') { teacherUid.value = data.teacherUid; } + if (typeof data?.endTimestamp === 'number') { + roomEndTimestamp.value = data.endTimestamp; + } + break; + } + case 'sev_room_end_timestamp_updated': { + roomEndTimestamp.value = packet.data.endTimestamp; + Toast.success('课程已续期'); + break; + } + case 'sev_room_will_expire': { + if (isRenewPrompting.value) { + break; + } + isRenewPrompting.value = true; + const baseEndTimestamp = roomEndTimestamp.value || packet.data.endTimestamp; + const minutesLeft = Math.max(1, Math.ceil(packet.data.remainingMs / 60000)); + ElMessageBox.prompt(`课程将在约 ${minutesLeft} 分钟后结束,是否续期?`, '续期', { + confirmButtonText: '确定', + cancelButtonText: '取消', + inputValue: '10', + inputPattern: /^\d+$/, + inputErrorMessage: '请输入分钟数', + }) + .then(({ value }) => { + const minutes = Number(value); + if (!Number.isFinite(minutes) || minutes <= 0) { + Toast.error('续期分钟数不正确'); + return; + } + const nextEndTimestamp = baseEndTimestamp + minutes * 60 * 1000; + meetingSocket.renewRoomEndTimestamp(socket, packet.data.fromRoomId, nextEndTimestamp); + }) + .catch(() => {}) + .finally(() => { + isRenewPrompting.value = false; + }); break; } case 'sev_set_main_video': { diff --git a/src/views/meeting/meeting-room/meeting-room-student.vue b/src/views/meeting/meeting-room/meeting-room-student.vue index 60953b4..719f67d 100644 --- a/src/views/meeting/meeting-room/meeting-room-student.vue +++ b/src/views/meeting/meeting-room/meeting-room-student.vue @@ -25,11 +25,19 @@ 投屏中 -
-
+
+
+
- {{ userName }} + {{ selfWindowName }} + 点击切换
@@ -107,32 +115,48 @@ /** 主讲短 UID */ const speakerUid = ref(null); /** 投屏用户 ID */ - const screenShareUid = ref(null); + const screenShareStreamUid = ref(null); + const screenShareOwnerUid = ref(null); + const studentPrimaryOverrideUid = ref(null); - const meeting = useAgoraMeeting(courseRoomId, userName, shortUid, { - onTokenWillExpire: () => { - Toast.warning('Token 即将过期,正在自动刷新...'); - }, - onTokenExpired: () => { - Toast.error('Token 已过期,请重新加入会议'); - }, - onConnectionStateChange: (state: string) => { - if (state === 'DISCONNECTED') { - Toast.warning('网络连接已断开'); - } else if (state === 'RECONNECTING') { - Toast.info('正在重新连接...'); - } - }, - onError: (error: Error) => { - Toast.error(error.message || '操作失败'); - }, - onLeft: () => { - Toast.success('已离开会议'); + const meeting = useAgoraMeeting( + courseRoomId, + userName, + shortUid, + { + onTokenWillExpire: () => { + Toast.warning('Token 即将过期,正在自动刷新...'); + }, + onTokenExpired: () => { + Toast.error('Token 已过期,请重新加入会议'); + }, + onConnectionStateChange: (state: string) => { + if (state === 'DISCONNECTED') { + Toast.warning('网络连接已断开'); + } else if (state === 'RECONNECTING') { + Toast.info('正在重新连接...'); + } + }, + onError: (error: Error) => { + Toast.error(error.message || '操作失败'); + }, + onLeft: () => { + Toast.success('已离开会议'); + }, }, + { autoPlayRemoteVideo: false } + ); + + /** 加入方式 */ + const joinMode = computed(() => 'student' as const); + /** 房间结束时间(毫秒时间戳) */ + const endTimestamp = computed(() => { + const t = Number(route.query.endTimestamp || 0); + return Number.isFinite(t) && t > 0 ? t : null; }); const socket = useWebSocket(); - const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId); + const meetingSocket = useMeetingSocket(courseRoomId, longUserId, userName, homeworkId, joinMode, endTimestamp); const streamType = useStreamType(meeting.client); const { isMuted, isVideoOff, muteLoading, videoLoading, leaveLoading, networkQuality, selectedUserUid, toggleMute, toggleVideo, leaveChannel, cleanup } = @@ -151,14 +175,51 @@ return qualityMap[networkQuality.value] || '网络未知'; }); - /** 主讲 UID(投屏优先,其次主讲) */ + const isTeacherDualStream = computed(() => Boolean(teacherUid.value && screenShareStreamUid.value)); + + const teacherPreferredPrimaryUid = computed(() => { + return screenShareStreamUid.value ?? speakerUid.value ?? (teacherUid.value || null); + }); + const primaryUid = computed(() => { - const candidate = screenShareUid.value ?? speakerUid.value ?? (teacherUid.value || null); - const local = meeting.localUser.uid; - if (candidate && local && candidate === local && teacherUid.value) { - return teacherUid.value; + if (isTeacherDualStream.value && studentPrimaryOverrideUid.value) { + const candidate = studentPrimaryOverrideUid.value; + if (candidate === teacherUid.value || candidate === screenShareStreamUid.value) { + return candidate; + } } - return candidate; + return teacherPreferredPrimaryUid.value; + }); + + const secondaryUid = computed(() => { + if (!isTeacherDualStream.value) { + return null; + } + if (!teacherUid.value || !screenShareStreamUid.value) { + return null; + } + return primaryUid.value === screenShareStreamUid.value ? teacherUid.value : screenShareStreamUid.value; + }); + + const selfWindowUid = computed(() => secondaryUid.value); + + const screenOwnerName = computed(() => { + const uid = screenShareOwnerUid.value; + if (!uid) { + return ''; + } + const u = meeting.remoteUsers.value.find((it: IAgoraRTCRemoteUser) => it.uid === uid) as (IAgoraRTCRemoteUser & { userName?: string }) | undefined; + return u?.userName || (uid === meeting.localUser.uid ? userName.value : `用户 ${uid}`); + }); + + const selfWindowName = computed(() => { + if (!selfWindowUid.value) { + return userName.value; + } + if (selfWindowUid.value === screenShareStreamUid.value) { + return `${screenOwnerName.value || '主讲'}(投屏)`; + } + return '主讲(摄像头)'; }); /** 主讲名称 */ @@ -167,12 +228,15 @@ if (!uid) { return '主讲'; } + if (screenShareStreamUid.value && uid === screenShareStreamUid.value) { + return `${screenOwnerName.value || '主讲'}(投屏)`; + } const u = meeting.remoteUsers.value.find((it: IAgoraRTCRemoteUser) => it.uid === uid) as (IAgoraRTCRemoteUser & { userName?: string }) | undefined; return u?.userName || (uid === meeting.localUser.uid ? userName.value : `用户 ${uid}`); }); /** 是否为投屏主画面 */ - const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareUid.value === primaryUid.value)); + const isPrimaryScreenSharing = computed(() => Boolean(primaryUid.value && screenShareStreamUid.value === primaryUid.value)); /** 舞台容器 DOM */ const stageRef = ref(null); @@ -301,16 +365,59 @@ () => primaryUid.value, (uid) => { ensurePrimaryVideoPlaying(uid); - } + if (uid) { + selectedUserUid.value = uid; + } + }, + { immediate: true } ); watch( () => meeting.remoteUsers.value.length, () => { ensurePrimaryVideoPlaying(primaryUid.value); + ensurePrimaryVideoPlaying(secondaryUid.value); } ); + watch( + () => secondaryUid.value, + (uid) => { + ensurePrimaryVideoPlaying(uid); + } + ); + + watch( + () => teacherPreferredPrimaryUid.value, + () => { + studentPrimaryOverrideUid.value = null; + } + ); + + watch( + () => isTeacherDualStream.value, + (enabled) => { + if (!enabled) { + studentPrimaryOverrideUid.value = null; + } + } + ); + + const handleSelfWindowClick = () => { + if (!isTeacherDualStream.value) { + return; + } + if (isDragging.value) { + return; + } + const uid = selfWindowUid.value; + if (!uid) { + return; + } + studentPrimaryOverrideUid.value = uid; + ensurePrimaryVideoPlaying(uid); + }; + const isLeaving = ref(false); type MeetingWsPacket = Parameters[0]; @@ -360,8 +467,11 @@ selectedUserUid.value = data.speakerUid; streamType.setMainVideo(data.speakerUid); } - if (data?.screenShareUid) { - screenShareUid.value = data.screenShareUid; + if (typeof data?.screenShareUid === 'number') { + screenShareStreamUid.value = data.screenShareUid; + } + if (typeof data?.screenShareOwnerUid === 'number') { + screenShareOwnerUid.value = data.screenShareOwnerUid; } break; } @@ -395,8 +505,12 @@ } case 'sev_start_screen_share': { const targetUid = packet.data?.targetUid; + const screenUid = packet.data?.screenUid; if (typeof targetUid === 'number') { - screenShareUid.value = targetUid; + screenShareOwnerUid.value = targetUid; + } + if (typeof screenUid === 'number') { + screenShareStreamUid.value = screenUid; Toast.info?.(`用户 ${targetUid} 开始投屏`); setTimeout(() => { ensurePrimaryVideoPlaying(primaryUid.value); @@ -407,9 +521,8 @@ case 'sev_stop_screen_share': { const targetUid = packet.data?.targetUid; if (typeof targetUid === 'number') { - if (screenShareUid.value === targetUid) { - screenShareUid.value = null; - } + screenShareOwnerUid.value = null; + screenShareStreamUid.value = null; Toast.info?.(`用户 ${targetUid} 停止投屏`); } break; @@ -426,8 +539,11 @@ speakerUid.value = roomData.speakerUid; selectedUserUid.value = roomData.speakerUid; streamType.setMainVideo(roomData.speakerUid); - if (roomData.screenShareUid) { - screenShareUid.value = roomData.screenShareUid; + if (typeof roomData.screenShareUid === 'number') { + screenShareStreamUid.value = roomData.screenShareUid; + } + if (typeof roomData.screenShareOwnerUid === 'number') { + screenShareOwnerUid.value = roomData.screenShareOwnerUid; } await meeting.initAndJoin(roomData); initSelfWindowPosition(); @@ -443,7 +559,8 @@ } case 'sev_class_ended': { classStatus.value = 'finished'; - screenShareUid.value = null; + screenShareOwnerUid.value = null; + screenShareStreamUid.value = null; leaveChannel().catch(() => {}); Toast.info?.('老师已下课,等待重新上课'); break; @@ -481,7 +598,10 @@ streamType.setMainVideo(roomData.speakerUid); classStatus.value = roomData?.classStatus || 'not_started'; if (roomData.screenShareUid) { - screenShareUid.value = roomData.screenShareUid; + screenShareStreamUid.value = roomData.screenShareUid; + } + if (typeof roomData.screenShareOwnerUid === 'number') { + screenShareOwnerUid.value = roomData.screenShareOwnerUid; } if (classStatus.value === 'in_class') { await meeting.initAndJoin(roomData); diff --git a/src/views/meeting/meeting-room/meeting-room-teacher.vue b/src/views/meeting/meeting-room/meeting-room-teacher.vue index 5d68791..b3910cb 100644 --- a/src/views/meeting/meeting-room/meeting-room-teacher.vue +++ b/src/views/meeting/meeting-room/meeting-room-teacher.vue @@ -145,7 +145,6 @@