From c378cc45f8474b93fc1b82c864a095b22b117d03 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 18:59:09 +0800 Subject: [PATCH] feat: add ClassesService with CRUD + student/teacher management --- apps/server/src/classes/classes.module.ts | 12 ++ apps/server/src/classes/classes.service.ts | 195 +++++++++++++++++++++ apps/server/src/classes/dto/class.dto.ts | 113 ++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 apps/server/src/classes/classes.module.ts create mode 100644 apps/server/src/classes/classes.service.ts create mode 100644 apps/server/src/classes/dto/class.dto.ts diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts new file mode 100644 index 0000000..6187dcd --- /dev/null +++ b/apps/server/src/classes/classes.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Class, ClassStudent, ClassTeacher } from '../entities'; +import { ClassesService } from './classes.service'; +import { OperationLogsModule } from '../operation-logs/operation-logs.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher]), OperationLogsModule], + providers: [ClassesService], + exports: [ClassesService], +}) +export class ClassesModule {} diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts new file mode 100644 index 0000000..0551541 --- /dev/null +++ b/apps/server/src/classes/classes.service.ts @@ -0,0 +1,195 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, Like } from 'typeorm'; +import { Class, ClassStudent, ClassTeacher } from '../entities'; +import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto } from './dto/class.dto'; + +interface RawStudentCount { + classId: string; + count: string; +} + +@Injectable() +export class ClassesService { + constructor( + @InjectRepository(Class) + private classRepo: Repository, + @InjectRepository(ClassStudent) + private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) + private classTeacherRepo: Repository, + ) {} + + async findAll(query: QueryClassDto) { + const where: Record = {}; + if (query.departmentId) where.departmentId = query.departmentId; + if (query.status) where.status = query.status; + if (query.classType) where.classType = query.classType; + if (query.keyword) where.name = Like(`%${query.keyword}%`); + + const classes = await this.classRepo.find({ + where, + order: { createdAt: 'DESC' as const }, + }); + + // count students per class + const studentCounts: RawStudentCount[] = await this.classStudentRepo + .createQueryBuilder('cs') + .select('cs.class_id', 'classId') + .addSelect('COUNT(cs.id)', 'count') + .where('cs.status = :status', { status: 'active' }) + .groupBy('cs.class_id') + .getRawMany(); + + const countMap = new Map(studentCounts.map((r) => [Number(r.classId), Number(r.count)])); + + return classes.map((c) => ({ + ...c, + studentCount: countMap.get(c.id) || 0, + })); + } + + async findOne(id: number) { + const cls = await this.classRepo.findOne({ where: { id } }); + if (!cls) throw new NotFoundException('班级不存在'); + + const students = await this.classStudentRepo.find({ + where: { classId: id }, + relations: ['student'], + }); + const teachers = await this.classTeacherRepo.find({ + where: { classId: id }, + relations: ['user'], + }); + + return { + ...cls, + students: students.map((s) => ({ + id: s.id, + studentId: s.studentId, + studentName: s.student?.name, + joinDate: s.joinDate, + leaveDate: s.leaveDate, + status: s.status, + })), + teachers: teachers.map((t) => ({ + id: t.id, + userId: t.userId, + username: t.user?.username, + roleType: t.roleType, + subject: t.subject, + })), + studentCount: students.filter((s) => s.status === 'active').length, + }; + } + + async create(dto: CreateClassDto) { + const { studentIds, teachers, ...classData } = dto; + + const cls = this.classRepo.create(classData); + const saved = await this.classRepo.save(cls); + + // add students + if (studentIds?.length) { + const entries = studentIds.map((sid: number) => + this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), + ); + await this.classStudentRepo.save(entries); + } + + // add teachers + if (teachers?.length) { + const entries = teachers.map((t) => + this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }), + ); + await this.classTeacherRepo.save(entries); + + // sync head/life/academic teacher IDs + await this.syncClassTeacherIds(saved.id); + } + + return this.findOne(saved.id); + } + + async update(id: number, dto: UpdateClassDto) { + const cls = await this.classRepo.findOne({ where: { id } }); + if (!cls) throw new NotFoundException('班级不存在'); + await this.classRepo.update(id, dto as Record); + return this.findOne(id); + } + + async remove(id: number) { + const cls = await this.classRepo.findOne({ where: { id } }); + if (!cls) throw new NotFoundException('班级不存在'); + await this.classRepo.remove(cls); + return { success: true }; + } + + async getStudents(classId: number) { + return this.classStudentRepo.find({ + where: { classId }, + relations: ['student'], + order: { createdAt: 'ASC' as const }, + }); + } + + async addStudents(classId: number, studentIds: number[]) { + const existing = await this.classStudentRepo.find({ + where: { classId, studentId: In(studentIds) }, + }); + const existingIds = new Set(existing.map((e) => e.studentId)); + const newIds = studentIds.filter((id) => !existingIds.has(id)); + + const entries = newIds.map((sid) => + this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }), + ); + if (entries.length) await this.classStudentRepo.save(entries); + + return { added: entries.length, skipped: studentIds.length - entries.length }; + } + + async removeStudent(classId: number, studentId: number) { + await this.classStudentRepo.delete({ classId, studentId }); + return { success: true }; + } + + async getTeachers(classId: number) { + return this.classTeacherRepo.find({ + where: { classId }, + relations: ['user'], + }); + } + + async addTeacher(classId: number, dto: AddTeacherDto) { + const existing = await this.classTeacherRepo.findOne({ + where: { classId, userId: dto.userId, roleType: dto.roleType }, + }); + if (existing) throw new BadRequestException('该教师已分配此角色'); + + const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject }); + await this.classTeacherRepo.save(entry); + + await this.syncClassTeacherIds(classId); + return entry; + } + + async removeTeacher(classId: number, userId: number) { + await this.classTeacherRepo.delete({ classId, userId }); + await this.syncClassTeacherIds(classId); + return { success: true }; + } + + private async syncClassTeacherIds(classId: number) { + const teachers = await this.classTeacherRepo.find({ where: { classId } }); + const updates: Record = {}; + const head = teachers.find((t) => t.roleType === 'head_teacher'); + const life = teachers.find((t) => t.roleType === 'life_teacher'); + const academic = teachers.find((t) => t.roleType === 'academic_teacher'); + if (head) updates.headTeacherId = head.userId; + if (life) updates.lifeTeacherId = life.userId; + if (academic) updates.academicTeacherId = academic.userId; + if (Object.keys(updates).length > 0) { + await this.classRepo.update(classId, updates); + } + } +} diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts new file mode 100644 index 0000000..e11797a --- /dev/null +++ b/apps/server/src/classes/dto/class.dto.ts @@ -0,0 +1,113 @@ +import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString } from 'class-validator'; + +export class CreateClassDto { + @IsString() @IsNotEmpty() + name: string; + + @IsString() @IsNotEmpty() + code: string; + + @IsOptional() @IsInt() + departmentId?: number; + + @IsString() @IsNotEmpty() + classType: string; + + @IsOptional() @IsDateString() + startDate?: string; + + @IsOptional() @IsDateString() + endDate?: string; + + @IsOptional() @IsString() + status?: string; + + @IsOptional() @IsInt() + headTeacherId?: number; + + @IsOptional() @IsInt() + lifeTeacherId?: number; + + @IsOptional() @IsInt() + academicTeacherId?: number; + + @IsOptional() @IsInt() + maxStudents?: number; + + @IsOptional() @IsString() + notes?: string; + + @IsOptional() @IsArray() + studentIds?: number[]; + + @IsOptional() @IsArray() + teachers?: Array<{ userId: number; roleType: string; subject?: string }>; +} + +export class UpdateClassDto { + @IsOptional() @IsString() + name?: string; + + @IsOptional() @IsString() + code?: string; + + @IsOptional() @IsInt() + departmentId?: number; + + @IsOptional() @IsString() + classType?: string; + + @IsOptional() @IsDateString() + startDate?: string; + + @IsOptional() @IsDateString() + endDate?: string; + + @IsOptional() @IsString() + status?: string; + + @IsOptional() @IsInt() + headTeacherId?: number; + + @IsOptional() @IsInt() + lifeTeacherId?: number; + + @IsOptional() @IsInt() + academicTeacherId?: number; + + @IsOptional() @IsInt() + maxStudents?: number; + + @IsOptional() @IsString() + notes?: string; +} + +export class QueryClassDto { + @IsOptional() @IsInt() + departmentId?: number; + + @IsOptional() @IsString() + status?: string; + + @IsOptional() @IsString() + classType?: string; + + @IsOptional() @IsString() + keyword?: string; +} + +export class AddStudentsDto { + @IsArray() @IsInt({ each: true }) + studentIds: number[]; +} + +export class AddTeacherDto { + @IsInt() + userId: number; + + @IsString() + roleType: string; + + @IsOptional() @IsString() + subject?: string; +}