feat(meeting): 添加学生列表排序功能
- 实现学生列表拖拽排序并保存到 Redis - 添加 WebSocket 消息类型支持学生排序变更 - 在教师端界面添加学生列表排序按钮和对话框 - 修改数据传输方式以支持排序信息同步 - 更新 API 接口以传递房间 ID 获取排序数据 - 添加 Redis 存储学生排序顺序的功能
This commit is contained in:
@ -1,18 +1,14 @@
|
||||
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class NonEmptyStringPipe implements PipeTransform<string, string> {
|
||||
public constructor(
|
||||
private readonly name?: string,
|
||||
private readonly code: 422 | 423 = 422
|
||||
) {}
|
||||
/**
|
||||
* 静态 transform 方法 (用于装饰器)
|
||||
* @param value - 待验证的值
|
||||
*/
|
||||
public transform(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
const message = `${this.name ?? '参数'}不能为空`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
throw new UnprocessableEntityException('参数不能为空');
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
@ -1,43 +1,25 @@
|
||||
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
|
||||
import { Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class Uint32Pipe implements PipeTransform<string, number> {
|
||||
public constructor(
|
||||
private readonly name?: string,
|
||||
private readonly code: 422 | 423 = 422
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 静态 transform 方法 (用于装饰器)
|
||||
* @param value - 待验证的值
|
||||
*/
|
||||
public transform(value: unknown): number {
|
||||
const field = this.name ?? '参数';
|
||||
if (value === undefined || value === null || value === '') {
|
||||
const message = `${field}不能为空`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
throw new UnprocessableEntityException('参数不能为空');
|
||||
}
|
||||
|
||||
if ((typeof value === 'string' && !/^\d+$/.test(value)) || isNaN(Number(value))) {
|
||||
const message = `${field}必须是数字`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
throw new UnprocessableEntityException('参数必须是数字');
|
||||
}
|
||||
|
||||
const num = Number(value);
|
||||
if (!Number.isSafeInteger(num)) {
|
||||
const message = `${field}超出安全整数范围`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
throw new UnprocessableEntityException('参数超出安全整数范围');
|
||||
} else if (num < 0 || num > 4294967295) {
|
||||
const message = `${field}必须在0到4294967295之间`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
throw new UnprocessableEntityException('参数必须在 0 到 4294967295 之间');
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { defineConfig } from '@mikro-orm/mysql';
|
||||
import { MeetingUser } from '../modules/meeting/entities/meeting-user.entity';
|
||||
import { MeetingUser } from '../entities/MeetingUser';
|
||||
|
||||
const config = defineConfig({
|
||||
dbName: process.env.DB_NAME || 'scs',
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
|
||||
import { ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiParam, ApiProperty, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { FailResult, OkResult } from '../../common/dto/result.dto';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { NonEmptyStringPipe } from '@/common/pipes/non-empty-string.pipe';
|
||||
import { Uint32Pipe } from '@/common/pipes/uint32.pipe';
|
||||
import { ApiCustomOkResponse } from '@/common/decorators/swagger.decorator';
|
||||
import { StudentListResponseDto, TokenResponseDto } from './meeting.dto';
|
||||
import { SaveStudentOrderDto, StudentListResponseDto, TokenResponseDto } from './meeting.dto';
|
||||
import { MeetingRedisService } from '../websocket/meeting-redis.service';
|
||||
import { MeetingWebSocketGateway } from '../websocket/meeting.websocket';
|
||||
|
||||
/**
|
||||
* 黑名单用户 DTO
|
||||
@ -28,7 +29,8 @@ class BlacklistUserDto {
|
||||
export class MeetingController {
|
||||
public constructor(
|
||||
private readonly meetingService: MeetingService,
|
||||
private readonly redis: MeetingRedisService
|
||||
private readonly redis: MeetingRedisService,
|
||||
private readonly websocketGateway: MeetingWebSocketGateway // 注入 WebSocket Gateway
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -44,7 +46,7 @@ export class MeetingController {
|
||||
})
|
||||
@ApiParam({ name: 'channelName', description: '需要加入的频道名(格式: `n_课程名称`)', example: 'n_1234567890' })
|
||||
@ApiParam({ name: 'uid', description: '用户的短 UID(0~4294967295)', example: '1001' })
|
||||
public async getToken(@Param('channelName', new NonEmptyStringPipe('channelName')) channelName: string, @Param('uid', new Uint32Pipe('uid')) uid: number) {
|
||||
public async getToken(@Param('channelName', NonEmptyStringPipe) channelName: string, @Param('uid', Uint32Pipe) uid: number) {
|
||||
const result = this.meetingService.generateToken(channelName, uid);
|
||||
if (!result) {
|
||||
return new FailResult('生成 Token 失败');
|
||||
@ -64,7 +66,7 @@ export class MeetingController {
|
||||
apiDescription: '房间黑名单(包含短 UID 和名称的数组)',
|
||||
resDescription: '房间黑名单(包含短 UID 和名称的数组)',
|
||||
})
|
||||
public async getBlacklist(@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string) {
|
||||
public async getBlacklist(@Param('roomId', NonEmptyStringPipe) roomId: string) {
|
||||
const list = await this.redis.getBlacklist(roomId);
|
||||
return new OkResult(list);
|
||||
}
|
||||
@ -82,10 +84,7 @@ export class MeetingController {
|
||||
apiDescription: '从房间黑名单移除指定用户(短 UID)',
|
||||
resDescription: '是否成功移除用户',
|
||||
})
|
||||
public async removeFromBlacklist(
|
||||
@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string,
|
||||
@Param('shortUid', new Uint32Pipe('shortUid')) shortUid: number
|
||||
) {
|
||||
public async removeFromBlacklist(@Param('roomId', NonEmptyStringPipe) roomId: string, @Param('shortUid', Uint32Pipe) shortUid: number) {
|
||||
await this.redis.removeFromBlacklist(roomId, shortUid);
|
||||
return new OkResult(true);
|
||||
}
|
||||
@ -93,20 +92,57 @@ export class MeetingController {
|
||||
/**
|
||||
* 通过作业 ID 查询学生详细信息列表
|
||||
*/
|
||||
@Get('students/:homeworkId')
|
||||
@Get('students')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiParam({ name: 'homeworkId', description: '作业 ID', example: '1234567890' })
|
||||
@ApiQuery({ name: 'homeworkId', description: '作业 ID', example: '1234567890' })
|
||||
@ApiQuery({ name: 'roomId', description: '房间 ID (可选,用于获取 Redis 中的排序)', required: false, example: 'n_1234567890' })
|
||||
@ApiCustomOkResponse({
|
||||
summary: '学生详细信息列表',
|
||||
model: StudentListResponseDto,
|
||||
apiDescription: '学生详细信息列表,包含长 ID、短 ID (可能为 null) 和姓名',
|
||||
resDescription: '学生详细信息列表',
|
||||
})
|
||||
public async getStudentDetails(@Param('homeworkId', new Uint32Pipe('homeworkId')) homeworkId: number) {
|
||||
const students = await this.meetingService.getStudentDetailsByHomeworkId(homeworkId);
|
||||
public async getStudentDetails(@Query('homeworkId', ParseIntPipe) homeworkId: number, @Query('roomId', NonEmptyStringPipe) roomId?: string) {
|
||||
const students = await this.meetingService.getStudentDetailsByHomeworkId(homeworkId, roomId);
|
||||
return new OkResult({
|
||||
total: students.length,
|
||||
students,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存学生排序顺序
|
||||
*/
|
||||
@Post('student-order')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiCustomOkResponse({
|
||||
summary: '保存学生排序顺序',
|
||||
model: Boolean,
|
||||
apiDescription: '保存学生排序顺序到 Redis 并发送给监控端',
|
||||
resDescription: '是否成功保存',
|
||||
})
|
||||
public async saveStudentOrder(@Body() dto: SaveStudentOrderDto) {
|
||||
// 1. 保存到 Redis
|
||||
await this.meetingService.saveStudentOrder(dto.roomId, dto.studentLongIds);
|
||||
|
||||
// 2. 获取房间内所有 socket 并过滤出监控端 (monitor 模式)
|
||||
const ns = this.websocketGateway.getServer();
|
||||
if (ns) {
|
||||
const sockets = await ns.in(dto.roomId).fetchSockets();
|
||||
const monitorSocketIds = sockets.filter((socket) => socket.data.joinMode === 'monitor').map((socket) => socket.id);
|
||||
|
||||
if (monitorSocketIds.length > 0) {
|
||||
// 发送给所有监控端的 socket
|
||||
ns.to(monitorSocketIds).emit('message', {
|
||||
type: 'sev_student_order_changed',
|
||||
data: {
|
||||
fromRoomId: dto.roomId,
|
||||
studentLongIds: dto.studentLongIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new OkResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,3 +38,14 @@ export class StudentListResponseDto {
|
||||
@ApiProperty({ description: '学生列表', type: [StudentDetailResponseDto] })
|
||||
students!: StudentDetailResponseDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存学生排序请求 DTO
|
||||
*/
|
||||
export class SaveStudentOrderDto {
|
||||
@ApiProperty({ description: '房间 ID', example: 'room_123' })
|
||||
roomId!: string;
|
||||
|
||||
@ApiProperty({ description: '学生长 ID 数组 (按排序顺序)', example: [1234567890, 1234567891, 1234567892] })
|
||||
studentLongIds!: number[];
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import { NacosConfigService } from '../../plugins/nacos/nacos-config.service';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import type { TokenResponseDto } from './meeting.dto';
|
||||
import { MeetingUser } from '@/entities/MeetingUser';
|
||||
import { MeetingRedisService } from '../websocket/meeting-redis.service';
|
||||
|
||||
/**
|
||||
* 学生信息接口
|
||||
@ -31,7 +32,8 @@ export interface StudentDetailInfo {
|
||||
export class MeetingService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly nacosConfig: NacosConfigService
|
||||
private readonly nacosConfig: NacosConfigService,
|
||||
private readonly redis: MeetingRedisService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -39,7 +41,7 @@ export class MeetingService {
|
||||
* @param homeworkId - 作业 ID
|
||||
* @returns 学生详细信息列表,包含长 ID、短 ID 和姓名
|
||||
*/
|
||||
public async getStudentDetailsByHomeworkId(homeworkId: number): Promise<StudentDetailInfo[]> {
|
||||
public async getStudentDetailsByHomeworkId(homeworkId: number, roomId?: string): Promise<StudentDetailInfo[]> {
|
||||
const id = Number(homeworkId);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return [];
|
||||
@ -87,11 +89,47 @@ export class MeetingService {
|
||||
}
|
||||
|
||||
// 转换为最终结果
|
||||
return results.map((row) => ({
|
||||
let finalResults = results.map((row) => ({
|
||||
longId: Number(row.longId),
|
||||
studentName: row.studentName || '',
|
||||
shortId: row.shortId ?? 0,
|
||||
}));
|
||||
|
||||
// 如果提供了 roomId,从 Redis 获取排序并应用
|
||||
if (roomId) {
|
||||
const orderedIds = await this.redis.getStudentOrder(roomId);
|
||||
if (orderedIds.length > 0) {
|
||||
// 创建映射表
|
||||
const studentMap = new Map<number, StudentDetailInfo>();
|
||||
finalResults.forEach((student) => {
|
||||
studentMap.set(student.longId, student);
|
||||
});
|
||||
|
||||
// 按 Redis 中的顺序重新排列
|
||||
const orderedResults: StudentDetailInfo[] = [];
|
||||
for (const longId of orderedIds) {
|
||||
const student = studentMap.get(longId);
|
||||
if (student) {
|
||||
orderedResults.push(student);
|
||||
studentMap.delete(longId); // 移除已添加的学生
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 Redis 中没有的学生 (新加入的学生)
|
||||
finalResults = [...orderedResults, ...Array.from(studentMap.values())];
|
||||
}
|
||||
}
|
||||
|
||||
return finalResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存学生排序顺序
|
||||
* @param roomId - 房间 ID
|
||||
* @param studentLongIds - 学生长 ID 数组 (按排序顺序)
|
||||
*/
|
||||
public async saveStudentOrder(roomId: string, studentLongIds: number[]): Promise<void> {
|
||||
await this.redis.setStudentOrder(roomId, studentLongIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -171,7 +209,7 @@ export class MeetingService {
|
||||
if (existingUser) {
|
||||
// 复用已有的短 UID
|
||||
console.log(`[MeetingService] 用户 ${longUserId} 复用短 UID: ${existingUser.id}`);
|
||||
return existingUser.id;
|
||||
return Number(existingUser.id);
|
||||
}
|
||||
|
||||
// 创建新记录(数据库自增 ID 会自动分配)
|
||||
@ -179,7 +217,7 @@ export class MeetingService {
|
||||
const newUser = this.em.create(MeetingUser, { longUserId: Number(userId), createdAt: new Date() });
|
||||
await this.em.flush();
|
||||
console.log(`[MeetingService] 为用户 ${longUserId} 分配短 UID: ${newUser.id}`);
|
||||
return newUser.id;
|
||||
return Number(newUser.id);
|
||||
} catch (error: any) {
|
||||
// 处理并发导致的唯一索引冲突 (MySQL Error 1062: Duplicate entry)
|
||||
if (error.code === 'ER_DUP_ENTRY' || error.message?.includes('Duplicate entry')) {
|
||||
@ -187,7 +225,7 @@ export class MeetingService {
|
||||
this.em.clear(); // 清除当前上下文,防止缓存干扰
|
||||
const retryUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
|
||||
if (retryUser) {
|
||||
return retryUser.id;
|
||||
return Number(retryUser.id);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
||||
@ -59,7 +59,7 @@ export class MeetingRedisService {
|
||||
* 用途:存储房间的全局状态信息
|
||||
* 数据结构:Hash
|
||||
* Key 格式:meeting:room:{roomId}
|
||||
* Hash 字段:
|
||||
* Hash 字段:
|
||||
* - classStatus: 课堂状态(not_started | in_class | finished)
|
||||
* - speakerUid: 当前主讲人短 UID(可选)
|
||||
* - teacherUid: 老师短 UID(可选)
|
||||
@ -68,6 +68,17 @@ export class MeetingRedisService {
|
||||
* 过期时间:24 小时
|
||||
*/
|
||||
ROOM_STATE: 'meeting:room:',
|
||||
|
||||
/**
|
||||
* 学生排序 Key
|
||||
* 用途:存储学生列表的排序顺序
|
||||
* 数据结构:List
|
||||
* Key 格式:meeting:student-order:{roomId}
|
||||
* List 内容:按顺序存储学生的长 ID (字符串)
|
||||
* 适用场景:教师端拖动排序后,监控端按相同顺序显示
|
||||
* 过期时间:24 小时
|
||||
*/
|
||||
STUDENT_ORDER: 'meeting:student-order:',
|
||||
};
|
||||
|
||||
constructor(private readonly redisService: RedisService) {
|
||||
@ -382,7 +393,9 @@ export class MeetingRedisService {
|
||||
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);
|
||||
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<string, string> = { socketIds: JSON.stringify(nextSocketIds) };
|
||||
@ -486,6 +499,42 @@ export class MeetingRedisService {
|
||||
return !socketChecks.includes(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置学生排序顺序
|
||||
* @param roomId - 房间 ID
|
||||
* @param studentLongIds - 学生长 ID 数组 (按排序顺序)
|
||||
*/
|
||||
async setStudentOrder(roomId: string, studentLongIds: number[]): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
|
||||
const client = this.getClient();
|
||||
await client.del(key); // 先删除旧数据
|
||||
if (studentLongIds.length > 0) {
|
||||
const stringIds = studentLongIds.map((id) => String(id));
|
||||
await client.rpush(key, ...stringIds);
|
||||
await client.expire(key, 24 * 60 * 60);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生排序顺序
|
||||
* @param roomId - 房间 ID
|
||||
* @returns 学生长 ID 数组 (按排序顺序),未设置返回空数组
|
||||
*/
|
||||
async getStudentOrder(roomId: string): Promise<number[]> {
|
||||
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
|
||||
const ids = await this.getClient().lrange(key, 0, -1);
|
||||
return ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理学生排序
|
||||
* @param roomId - 房间 ID
|
||||
*/
|
||||
async clearStudentOrder(roomId: string): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
|
||||
await this.getClient().del(key);
|
||||
}
|
||||
|
||||
// ==================== 清理 ====================
|
||||
|
||||
/**
|
||||
|
||||
@ -62,6 +62,14 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
this.logger.info({}, message, 'MeetingWebSocket');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Socket.IO Server 实例
|
||||
* @returns Socket.IO Namespace 实例
|
||||
*/
|
||||
public getServer(): MeetingNamespace | null {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找目标用户的所有 Socket(本节点 + 跨节点)
|
||||
* @param roomId - 服务端房间 ID(Socket.IO 房间名)
|
||||
@ -239,7 +247,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
const roomId = courseRoomId;
|
||||
|
||||
// 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID)
|
||||
const shortUid = socket.data.user?.shortUid;
|
||||
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 =
|
||||
@ -697,4 +705,36 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理学生排序消息 (仅创建者)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_student_order_changed')
|
||||
public async handleStudentOrderChanged(
|
||||
@MessageBody() data: { roomId: string; studentLongIds: number[] },
|
||||
@ConnectedSocket() socket: MeetingSocket
|
||||
): Promise<void> {
|
||||
if (!data?.roomId || !Array.isArray(data.studentLongIds)) {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试设置学生排序:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
// 保存到 Redis
|
||||
await this.redisService.setStudentOrder(data.roomId, data.studentLongIds);
|
||||
// 获取房间内所有 socket 并过滤出监控端 (monitor 模式)
|
||||
const ns = this.server;
|
||||
if (ns) {
|
||||
const sockets = await ns.in(data.roomId).fetchSockets();
|
||||
const monitorSocketIds = sockets.filter((s) => s.data.joinMode === 'monitor').map((s) => s.id);
|
||||
|
||||
if (monitorSocketIds.length > 0) {
|
||||
// 发送给所有监控端的 socket
|
||||
ns.to(monitorSocketIds).emit('message', { type: 'sev_student_order_changed', data: { fromRoomId: data.roomId, studentLongIds: data.studentLongIds } });
|
||||
}
|
||||
}
|
||||
this.log(`创建者更新了学生排序:roomId=${data.roomId}, 学生数量=${data.studentLongIds.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -214,6 +214,13 @@ export interface MeetingRoomEndTimestampUpdatedData {
|
||||
endTimestamp: number;
|
||||
}
|
||||
|
||||
export interface MeetingStudentOrderChangedData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
/** 学生长 ID 数组 (按排序顺序) */
|
||||
studentLongIds: number[];
|
||||
}
|
||||
|
||||
export type MeetingDownlinkPacket =
|
||||
/** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */
|
||||
| ErrorMessage
|
||||
@ -237,6 +244,8 @@ export type MeetingDownlinkPacket =
|
||||
| MeetingWsMessagePacket<'sev_start_screen_share', MeetingScreenShareNotifyData>
|
||||
/** 停止投屏通知 */
|
||||
| MeetingWsMessagePacket<'sev_stop_screen_share', MeetingScreenShareNotifyData>
|
||||
/** 学生排序变更通知 */
|
||||
| MeetingWsMessagePacket<'sev_student_order_changed', MeetingStudentOrderChangedData>
|
||||
/** 解除禁麦通知 */
|
||||
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
|
||||
/** 解除禁视频通知 */
|
||||
@ -265,6 +274,7 @@ export type ClientToServerMessageType =
|
||||
| 'client_start_class' // 开始课程(仅创建者)
|
||||
| 'client_start_screen_share' // 开始投屏
|
||||
| 'client_stop_screen_share' // 停止投屏
|
||||
| 'client_student_order_changed' // 学生排序变更(仅创建者)
|
||||
| 'client_unmute_audio' // 解除禁麦(仅创建者)
|
||||
| 'client_unmute_video' // 解除禁视频(仅创建者)
|
||||
| 'client_renew_room_end_timestamp'; // 续期房间结束时间(仅创建者)
|
||||
@ -283,6 +293,7 @@ export type ServerToClientMessageType =
|
||||
| 'sev_set_main_video' // 设置主屏通知
|
||||
| 'sev_start_screen_share' // 开始投屏通知(广播给所有人)
|
||||
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
|
||||
| 'sev_student_order_changed' // 学生排序变更通知(广播给监控端)
|
||||
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
|
||||
| 'sev_unmute_video' // 解除禁视频通知(转发给目标用户)
|
||||
| 'sev_room_will_expire' // 房间即将到期提醒(仅老师端)
|
||||
|
||||
@ -36,7 +36,21 @@ export function removeFromBlacklistAxios(roomId: string, shortUid: number): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑名单用户项(包含短 UID 和名称)
|
||||
* 获取学生详细信息列表 (带排序)
|
||||
* @param homeworkId - 作业 ID
|
||||
* @param roomId - 房间 ID (用于获取 Redis 中的排序)
|
||||
*/
|
||||
export function getStudentDetailsAxios(homeworkId: number, roomId: string): Promise<Response<StudentDetailItem[]>> {
|
||||
return axios.get<unknown, Response<StudentDetailItem[]>>(`${import.meta.env.VITE_MEETING_BASE_URL}/meeting/students`, {
|
||||
params: {
|
||||
homeworkId,
|
||||
roomId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑名单用户项 (包含短 UID 和名称)
|
||||
*/
|
||||
export interface BlacklistItem {
|
||||
/** 短 UID */
|
||||
@ -45,6 +59,18 @@ export interface BlacklistItem {
|
||||
userName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生详细信息项
|
||||
*/
|
||||
export interface StudentDetailItem {
|
||||
/** 学生长 ID (数据库 ID) */
|
||||
longId: number;
|
||||
/** 学生短 ID (声网短 ID) */
|
||||
shortId: number;
|
||||
/** 学生姓名 */
|
||||
studentName: string;
|
||||
}
|
||||
|
||||
export interface ReqMeetingToken {
|
||||
channelName: string;
|
||||
uid: number;
|
||||
|
||||
@ -94,6 +94,10 @@
|
||||
<icon-mdi-monitor></icon-mdi-monitor>
|
||||
<span class="btn-text">黑名单</span>
|
||||
</button>
|
||||
<button v-if="isHost" class="control-btn" @click="openStudentSort">
|
||||
<icon-mdi-format-list-bulleted></icon-mdi-format-list-bulleted>
|
||||
<span class="btn-text">学生列表</span>
|
||||
</button>
|
||||
<button class="control-btn" :class="{ active: !isMuted }" :disabled="muteLoading" @click="toggleMute">
|
||||
<icon-mdi-loading v-if="muteLoading" class="animate-spin"></icon-mdi-loading>
|
||||
<template v-else>
|
||||
@ -138,6 +142,8 @@
|
||||
</li>
|
||||
</ul>
|
||||
</el-drawer>
|
||||
<!-- 学生列表排序弹窗 -->
|
||||
<student-sort-dialog v-model="studentSortDialogVisible" :roomId="courseRoomId" :students="studentListForSort"></student-sort-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -155,6 +161,7 @@
|
||||
import IconMdiMonitor from '~icons/mdi/monitor';
|
||||
import IconMdiMonitorShare from '~icons/mdi/monitor-share';
|
||||
import IconMdiWifi from '~icons/mdi/wifi';
|
||||
import IconMdiFormatListBulleted from '~icons/mdi/format-list-bulleted';
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStreamType } from './useStreamType';
|
||||
@ -163,7 +170,8 @@
|
||||
import { useAgoraMeeting } from './useAgoraMeeting';
|
||||
import { destroyMeetingSocket, useMeetingSocket, useWebSocket } from './websocket';
|
||||
import { ElMessageBox, ElMessage as Toast } from 'element-plus';
|
||||
import { getBlacklistAxios, removeFromBlacklistAxios } from '@/api/meeting';
|
||||
import { getBlacklistAxios, getStudentDetailsAxios, removeFromBlacklistAxios } from '@/api/meeting';
|
||||
import StudentSortDialog from './student-sort-dialog.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@ -572,7 +580,12 @@
|
||||
});
|
||||
|
||||
/** 黑名单抽屉可见性 */
|
||||
/** 黑名单弹窗可见性 */
|
||||
const blacklistVisible = ref(false);
|
||||
/** 学生列表排序弹窗可见性 */
|
||||
const studentSortDialogVisible = ref(false);
|
||||
/** 用于排序的学生列表 */
|
||||
const studentListForSort = ref<Array<{ longId: number; studentName: string; shortId: number }>>([]);
|
||||
/** 黑名单列表(包含短 UID 和名称) */
|
||||
interface BlacklistItem {
|
||||
shortUid: number;
|
||||
@ -603,6 +616,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开学生列表排序弹窗 */
|
||||
async function openStudentSort(): Promise<void> {
|
||||
if (!isHost.value) {
|
||||
return;
|
||||
}
|
||||
if (!roomId.value || !homeworkId.value) {
|
||||
Toast.warning('房间未就绪,稍后再试');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await getStudentDetailsAxios(homeworkId.value, roomId.value);
|
||||
if (result.data) {
|
||||
studentListForSort.value = result.data;
|
||||
studentSortDialogVisible.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[学生列表] 获取失败:', error);
|
||||
Toast.error('获取学生列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 解除黑名单 */
|
||||
async function removeFromBlacklist(itemShortUid: number): Promise<void> {
|
||||
if (!roomId.value) {
|
||||
|
||||
244
src/views/meeting/meeting-room/student-sort-dialog.vue
Normal file
244
src/views/meeting/meeting-room/student-sort-dialog.vue
Normal file
@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" title="学生列表排序" width="500px" :closeOnClickModal="false">
|
||||
<div class="student-sort-container">
|
||||
<el-scrollbar max-height="400px">
|
||||
<div
|
||||
v-for="(student, index) in sortedStudents"
|
||||
:key="student.longId"
|
||||
class="student-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, index)"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover="handleDragOver"
|
||||
@drop="handleDrop($event, index)"
|
||||
>
|
||||
<span class="drag-handle">☰</span>
|
||||
<span class="student-index">{{ index + 1 }}</span>
|
||||
<span class="student-name">{{ student.studentName }}</span>
|
||||
<span class="student-id">ID: {{ student.shortId }}</span>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="cancel">取消</el-button>
|
||||
<el-button type="primary" @click="saveAndClose">保存排序</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
export interface StudentInfo {
|
||||
/** 学生长 ID */
|
||||
longId: number;
|
||||
/** 学生姓名 */
|
||||
studentName: string;
|
||||
/** 学生短 ID */
|
||||
shortId: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** 是否显示弹窗 */
|
||||
modelValue: boolean;
|
||||
/** 房间 ID */
|
||||
roomId: string;
|
||||
/** 学生列表 */
|
||||
students: StudentInfo[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
/** 本地排序后的学生列表 */
|
||||
const sortedStudents = ref<StudentInfo[]>([...props.students]);
|
||||
|
||||
/** 控制弹窗显示 */
|
||||
const dialogVisible = ref(props.modelValue);
|
||||
|
||||
/** 拖动的元素索引 */
|
||||
let draggedIndex = -1;
|
||||
|
||||
/**
|
||||
* 监听弹窗开启,初始化排序
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
dialogVisible.value = newVal;
|
||||
if (newVal) {
|
||||
// 弹窗打开时,复制一份学生列表用于排序
|
||||
sortedStudents.value = [...props.students];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 监听弹窗关闭状态
|
||||
*/
|
||||
watch(dialogVisible, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理拖动开始
|
||||
*/
|
||||
function handleDragStart(event: DragEvent, index: number) {
|
||||
draggedIndex = index;
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
|
||||
// 添加拖动样式
|
||||
if (event.target instanceof HTMLElement) {
|
||||
event.target.classList.add('dragging');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拖动结束
|
||||
*/
|
||||
function handleDragEnd() {
|
||||
draggedIndex = -1;
|
||||
|
||||
// 移除拖动样式
|
||||
const draggingEl = document.querySelector('.dragging');
|
||||
if (draggingEl) {
|
||||
draggingEl.classList.remove('dragging');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拖动进入
|
||||
*/
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理放置
|
||||
*/
|
||||
function handleDrop(event: DragEvent, toIndex: number) {
|
||||
event.preventDefault();
|
||||
|
||||
if (draggedIndex === -1 || draggedIndex === toIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 移动数组元素
|
||||
const [removed] = sortedStudents.value.splice(draggedIndex, 1);
|
||||
sortedStudents.value.splice(toIndex, 0, removed);
|
||||
|
||||
console.log(
|
||||
'[学生排序] 新顺序:',
|
||||
sortedStudents.value.map((s) => s.longId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存排序并关闭弹窗
|
||||
*/
|
||||
async function saveAndClose() {
|
||||
try {
|
||||
const studentLongIds = sortedStudents.value.map((s) => s.longId);
|
||||
|
||||
const response = await fetch('/meeting/student-order', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roomId: props.roomId,
|
||||
studentLongIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
|
||||
ElMessage.success('排序已保存');
|
||||
dialogVisible.value = false;
|
||||
} catch (error) {
|
||||
console.error('[学生排序] 保存失败:', error);
|
||||
ElMessage.error('保存失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消并关闭弹窗
|
||||
*/
|
||||
function cancel() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.student-sort-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.student-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
cursor: move;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: #e4e7ed;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.5;
|
||||
border-color: #409eff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
font-size: 20px;
|
||||
color: #909399;
|
||||
margin-right: 12px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.student-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.student-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.student-id {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user