Files
gongxue-base/apps/server/src/students/students.service.ts
wangziqi 5df70a8af0 feat: P2-12 Teacher profile/workspace + P2-13 Multi-class comparison
P2-12 Teacher Management:
- Add profile JSON field (subjects, joinedAt, qualifications) to User entity
- Add GET/PUT /rbac/users/:id/profile endpoints with UpdateProfileDto
- Add GET /rbac/teacher-workspace endpoint returning assignedClasses,
  todaySchedules, and myStudents
- Create /teacher-workspace page with tabs (My Classes, Today's Schedule,
  My Students)
- Add route with PermissionRoute and menu item in MainLayout

P2-13 Student Archive Multi-class:
- Add GET /students/:id/compare-classes endpoint returning side-by-side
  enrollment data with per-class attendance statistics
- Students module now includes ClassStudent and AttendanceRecord entities
- No 2-enrollment cap existed in the codebase; student enrollments
  already return all records via class-student table
2026-07-05 20:42:27 +08:00

197 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, Not, In } from 'typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
@Injectable()
export class StudentsService {
constructor(
@InjectRepository(Student) private repo: Repository<Student>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
) {}
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean }) {
const where: any = {};
if (query?.name) where.name = Like(`%${query.name}%`);
if (query?.status) {
where.status = query.status;
} else if (!query?.includeArchived) {
where.status = Not('archived');
}
return this.repo.find({ where, order: { createdAt: 'DESC' } });
}
async findOne(id: number) {
const student = await this.repo.findOne({
where: { id },
relations: ['occupancies', 'occupancies.room'],
});
if (!student) throw new NotFoundException('学生不存在');
return student;
}
async create(dto: CreateStudentDto) {
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateStudentDto) {
await this.findOne(id);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
const student = await this.findOne(id);
if (student.status === 'archived') {
throw new BadRequestException('该学生已归档');
}
// 软删除:归档而非物理删除,保留历史数据
await this.repo.update(id, { status: 'archived' });
return { message: '已归档(数据已保留,可随时恢复)' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生');
const students = await this.repo.find({ where: { id: In(ids) } });
const skipped: string[] = [];
const targetIds: number[] = [];
for (const s of students) {
if (s.status === 'archived') skipped.push(s.name);
else targetIds.push(s.id);
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 人(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
}
async restore(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('该学生未被归档');
}
await this.repo.update(id, { status: 'active' });
return { message: '已恢复' };
}
async batchImport(
rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) {
skipped++;
continue;
}
await this.repo.save(
this.repo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
}),
);
imported++;
}
return {
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
}
async compareClasses(studentId: number) {
const student = await this.repo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');
// Get all class enrollments for this student
const enrollments = await this.classStudentRepo.find({
where: { studentId },
relations: ['class'],
});
if (enrollments.length === 0) {
return { student, enrollments: [] };
}
// For each enrollment, compute attendance stats
const classIds = enrollments.map((e) => e.classId);
const attendanceRecords = await this.attendanceRepo.find({
where: { studentId, classId: In(classIds) },
});
// Group attendance by class
const attendanceByClass = new Map<number, AttendanceRecord[]>();
for (const r of attendanceRecords) {
const list = attendanceByClass.get(r.classId!) || [];
list.push(r);
attendanceByClass.set(r.classId!, list);
}
const comparison = enrollments.map((e) => {
const records = attendanceByClass.get(e.classId) || [];
const total = records.length;
const present = records.filter((r) => r.status === 'present' || r.status === '正常').length;
const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length;
const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length;
const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length;
return {
classId: e.classId,
className: e.class?.name || '',
classType: e.class?.classType || '',
joinDate: e.joinDate,
leaveDate: e.leaveDate,
status: e.status,
attendanceStats: {
total,
present,
absent,
late,
leave,
rate: total > 0 ? Math.round((present / total) * 100) : 0,
},
};
});
return { student, enrollments: comparison };
}
}