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
This commit is contained in:
2026-07-05 20:42:27 +08:00
parent 80ce911b88
commit 5df70a8af0
11 changed files with 438 additions and 6 deletions

View File

@@ -41,6 +41,9 @@ export class User {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'profile', type: 'simple-json', nullable: true })
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string };
@ManyToMany(() => Role, (role) => role.users)
@JoinTable({
name: 'user_roles',

View File

@@ -66,3 +66,16 @@ export class ResetPasswordDto {
@MinLength(4)
password: string;
}
export class UpdateProfileDto {
@IsOptional()
subjects?: string[];
@IsOptional()
@IsString()
joinedAt?: string;
@IsOptional()
@IsString()
qualifications?: string;
}

View File

@@ -17,6 +17,7 @@ import {
CreateUserDto,
UpdateUserDto,
ResetPasswordDto,
UpdateProfileDto,
} from './dto/rbac.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@@ -216,4 +217,46 @@ export class RbacController {
throw new BadRequestException(e.message);
}
}
// ---- 用户资料 ----
@Get('users/:id/profile')
@RequirePermission('user:view')
getUserProfile(@Param('id') id: string) {
return this.rbacService.getUserProfile(+id);
}
@Put('users/:id/profile')
@RequirePermission('user:edit')
async updateUserProfile(
@Param('id') id: string,
@Body() dto: UpdateProfileDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.updateUserProfile(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账号',
action: '更新资料',
targetId: +id,
targetType: 'user',
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
}
}
// ---- 教师工作台 ----
@Get('teacher-workspace')
@RequirePermission('class:view')
async getTeacherWorkspace(@Request() req: any) {
return this.rbacService.getTeacherWorkspace(req.user?.id);
}
}

View File

@@ -1,12 +1,12 @@
import { Module, OnModuleInit, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Permission, Role, User } from '../entities';
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
import { RbacService } from './rbac.service';
import { RbacController } from './rbac.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([Permission, Role, User]), forwardRef(() => AuthModule)],
imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student]), forwardRef(() => AuthModule)],
controllers: [RbacController],
providers: [RbacService],
exports: [RbacService],

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, In } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { Permission, Role, User } from '../entities';
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
@@ -129,6 +129,11 @@ export class RbacService {
@InjectRepository(Permission) private permRepo: Repository<Permission>,
@InjectRepository(Role) private roleRepo: Repository<Role>,
@InjectRepository(User) private userRepo: Repository<User>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
async seedData(): Promise<void> {
@@ -351,4 +356,101 @@ export class RbacService {
await this.userRepo.remove(user);
return { message: '用户已删除' };
}
// ---- 用户资料 ----
async getUserProfile(id: number) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new Error('用户不存在');
return {
id: user.id,
username: user.username,
name: user.name,
profile: user.profile || {},
};
}
async updateUserProfile(id: number, dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }) {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new Error('用户不存在');
const current = user.profile || {};
user.profile = {
subjects: dto.subjects !== undefined ? dto.subjects : current.subjects,
joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt,
qualifications: dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
};
await this.userRepo.save(user);
return { message: '资料已更新', profile: user.profile };
}
// ---- 教师工作台 ----
async getTeacherWorkspace(userId: number) {
// Find all classes where this user is a teacher
const teacherAssignments = await this.classTeacherRepo.find({
where: { userId },
relations: ['class'],
});
const classIds = [...new Set(teacherAssignments.map((t) => t.classId))];
if (classIds.length === 0) {
return { assignedClasses: [], todaySchedules: [], myStudents: [] };
}
// Get assigned classes
const assignedClasses = teacherAssignments.map((t) => ({
classId: t.classId,
className: t.class?.name || '',
classCode: t.class?.code || '',
roleType: t.roleType,
subject: t.subject,
}));
// Get today's day of week (1=Monday, 7=Sunday)
const today = new Date();
const weekDay = today.getDay(); // 0=Sun → convert to 1-7
const adjustedWeekDay = weekDay === 0 ? 7 : weekDay;
const todayStr = today.toISOString().slice(0, 10);
// Get today's schedules for assigned classes
const todaySchedules = await this.classScheduleRepo
.createQueryBuilder('cs')
.where('cs.classId IN (:...classIds)', { classIds })
.andWhere('cs.weekDay = :weekDay', { weekDay: adjustedWeekDay })
.andWhere('cs.startDate <= :today', { today: todayStr })
.andWhere('cs.endDate >= :today', { today: todayStr })
.andWhere('cs.status = :status', { status: 'active' })
.orderBy('cs.startTime', 'ASC')
.getMany();
// Get students in assigned classes
const classStudents = await this.classStudentRepo.find({
where: { classId: In(classIds), status: 'active' },
relations: ['student', 'class'],
});
const myStudents = classStudents.map((cs) => ({
studentId: cs.studentId,
studentName: cs.student?.name || '',
studentNo: cs.student?.studentNo || '',
className: cs.class?.name || '',
classId: cs.classId,
joinDate: cs.joinDate,
}));
return {
assignedClasses,
todaySchedules: todaySchedules.map((s) => ({
id: s.id,
classId: s.classId,
weekDay: s.weekDay,
startTime: s.startTime,
endTime: s.endTime,
subject: s.subject,
scheduleType: s.scheduleType,
})),
myStudents,
};
}
}

View File

@@ -280,4 +280,10 @@ export class StudentsController {
});
return result;
}
@Get(':id/compare-classes')
@RequirePermission('student:view')
compareClasses(@Param('id') id: string) {
return this.service.compareClasses(+id);
}
}

View File

@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { StudentsService } from './students.service';
import { StudentsController } from './students.controller';
@Module({
imports: [TypeOrmModule.forFeature([Student])],
imports: [TypeOrmModule.forFeature([Student, ClassStudent, AttendanceRecord])],
controllers: [StudentsController],
providers: [StudentsService],
exports: [StudentsService],

View File

@@ -2,11 +2,18 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
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>) {}
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 = {};
@@ -129,4 +136,61 @@ export class StudentsService {
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 };
}
}