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:
@ -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 请求
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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 和名称)
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user