feat(meeting): 添加学生排序功能和黑名单用户DTO

- 在MeetingController中添加BlacklistUserDto导入并移除内联定义
- 将BlacklistUserDto移动到meeting.dto.ts文件中并添加数据验证装饰器
- 在SaveStudentOrderDto中添加@IsNotEmpty和@IsArray验证规则
- 创建saveStudentOrderAxios API函数用于保存学生排序
- 更新MeetingRoomTeacher组件以处理新的API响应格式
- 使用vue-slicksort库替换原生拖拽实现学生排序功能
- 修改StudentSortDialog组件使用SlickList和SlickItem组件
- 通过API调用保存排序结果并添加成功提示
- 调整样式以适配新的排序组件结构
This commit is contained in:
2026-03-14 21:09:09 +08:00
parent d0c8ff4e47
commit bc5fa9a366
5 changed files with 55 additions and 113 deletions

View File

@ -1,25 +1,14 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { ApiParam, ApiProperty, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ApiParam, 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 { SaveStudentOrderDto, StudentDetailResponseDto, TokenResponseDto } from './meeting.dto';
import { BlacklistUserDto, SaveStudentOrderDto, StudentDetailResponseDto, TokenResponseDto } from './meeting.dto';
import { MeetingRedisService } from '../websocket/meeting-redis.service';
import { MeetingWebSocketGateway } from '../websocket/meeting.websocket';
/**
* 黑名单用户 DTO
*/
class BlacklistUserDto {
@ApiProperty({ description: '短 UID', example: 1001 })
shortUid?: number;
@ApiProperty({ description: '用户名称', example: '张三' })
userName?: string;
}
/**
* 会议控制器
* 处理会议相关的 API 请求

View File

@ -1,5 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsString } from 'class-validator';
/**
* 黑名单用户 DTO
*/
export class BlacklistUserDto {
@ApiProperty({ description: '短 UID', example: 1001 })
shortUid?: number;
@ApiProperty({ description: '用户名称', example: '张三' })
userName?: string;
}
/**
* Token 响应数据 DTO
*/
@ -33,8 +44,12 @@ export class StudentDetailResponseDto {
*/
export class SaveStudentOrderDto {
@ApiProperty({ description: '房间 ID', example: 'room_123' })
@IsNotEmpty({ message: 'roomId 不能为空' })
@IsString({ message: 'roomId 必须是字符串' })
roomId!: string;
@ApiProperty({ description: '学生长 ID 数组 (按排序顺序)', example: [1234567890, 1234567891, 1234567892] })
@IsNotEmpty({ message: 'studentLongIds 不能为空' })
@IsArray({ message: 'studentLongIds 必须是数组' })
studentLongIds!: number[];
}

View File

@ -49,6 +49,18 @@ export function getStudentDetailsAxios(homeworkId: number, roomId: string): Prom
});
}
/**
* 保存学生排序顺序
* @param roomId - 房间 ID
* @param studentLongIds - 学生长 ID 数组 (按排序顺序)
*/
export function saveStudentOrderAxios(roomId: string, studentLongIds: number[]): Promise<Response<boolean>> {
return axios.post<unknown, Response<boolean>>(`${import.meta.env.VITE_MEETING_BASE_URL}/meeting/student-order`, {
roomId,
studentLongIds,
});
}
/**
* 黑名单用户项 (包含短 UID 和名称)
*/

View File

@ -627,7 +627,8 @@
}
try {
const result = await getStudentDetailsAxios(homeworkId.value, roomId.value);
if (result.data) {
// 现在后端直接返回数组,不再包装在 data.students 中
if (result.data && Array.isArray(result.data)) {
studentListForSort.value = result.data;
studentSortDialogVisible.value = true;
}

View File

@ -1,22 +1,15 @@
<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>
<div id="student-sort-container" class="student-sort-container">
<el-scrollbar max-height="400px" viewStyle="position: relative;overflow-x: hidden;">
<slick-list v-model:list="sortedStudents" lockAxis="y" tag="div" axis="y" class="slick-list">
<slick-item v-for="(item, index) in sortedStudents" :key="item.shortId" :index="index" tag="div" class="student-item">
<span class="drag-handle"></span>
<span class="student-index">{{ index + 1 }}</span>
<span class="student-name">{{ item.studentName }}</span>
<span class="student-id">ID: {{ item.shortId }}</span>
</slick-item>
</slick-list>
</el-scrollbar>
</div>
<template #footer>
@ -31,6 +24,8 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { ref, watch } from 'vue';
import { SlickItem, SlickList } from 'vue-slicksort';
import { saveStudentOrderAxios } from '@/api/meeting';
export interface StudentInfo {
/** 学生长 ID */
@ -60,9 +55,6 @@
/** 控制弹窗显示 */
const dialogVisible = ref(props.modelValue);
/** 拖动的元素索引 */
let draggedIndex = -1;
/**
* 监听弹窗开启,初始化排序
*/
@ -85,60 +77,6 @@
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)
);
}
/**
* 保存排序并关闭弹窗
*/
@ -146,23 +84,14 @@
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,
}),
});
const result = await saveStudentOrderAxios(props.roomId, studentLongIds);
if (!response.ok) {
if (result.data) {
ElMessage.success('排序已保存');
dialogVisible.value = false;
} else {
throw new Error('保存失败');
}
ElMessage.success('排序已保存');
dialogVisible.value = false;
} catch (error) {
console.error('[学生排序] 保存失败:', error);
ElMessage.error('保存失败,请重试');
@ -180,9 +109,15 @@
<style scoped lang="scss">
.student-sort-container {
padding: 10px 0;
position: relative;
}
.slick-list {
width: 100%;
}
.student-item {
z-index: 999999;
display: flex;
align-items: center;
padding: 12px 16px;
@ -190,18 +125,8 @@
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);
}
user-select: none;
.drag-handle {
font-size: 20px;