From 5df70a8af0cb4b96f8db64be9e597a6f8eab6c41 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 20:42:27 +0800 Subject: [PATCH] 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 --- apps/admin/src/App.tsx | 10 + apps/admin/src/layouts/MainLayout.tsx | 2 + .../src/pages/TeacherWorkspace/index.tsx | 187 ++++++++++++++++++ apps/server/src/entities/user.entity.ts | 3 + apps/server/src/rbac/dto/rbac.dto.ts | 13 ++ apps/server/src/rbac/rbac.controller.ts | 43 ++++ apps/server/src/rbac/rbac.module.ts | 4 +- apps/server/src/rbac/rbac.service.ts | 106 +++++++++- .../src/students/students.controller.ts | 6 + apps/server/src/students/students.module.ts | 4 +- apps/server/src/students/students.service.ts | 66 ++++++- 11 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 apps/admin/src/pages/TeacherWorkspace/index.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 93c6743..f2a3005 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -24,6 +24,7 @@ import SchedulesPage from './pages/Schedules'; import RolesPage from './pages/Roles'; import PermissionsPage from './pages/Permissions'; import AttendancePage from './pages/Attendance'; +import TeacherWorkspacePage from './pages/TeacherWorkspace'; import PermissionRoute from './components/PermissionRoute'; const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -220,6 +221,15 @@ const App: React.FC = () => { } /> + + + + + } + /> diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 67e319f..5d2e352 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -23,6 +23,7 @@ import { SafetyOutlined, KeyOutlined, CheckCircleOutlined, + LaptopOutlined, } from '@ant-design/icons'; import { usePermission } from '../hooks/usePermission'; @@ -51,6 +52,7 @@ const allMenuItems: MenuItemType[] = [ { key: '/deposits', icon: , label: '押金管理', permission: 'deposit:view' }, { key: '/bills', icon: , label: '账单管理', permission: 'bill:view' }, { key: '/classes', icon: , label: '班级管理', permission: 'class:view' }, + { key: '/teacher-workspace', icon: , label: '教师工作台', permission: 'class:view' }, { key: 'classroom-group', icon: , diff --git a/apps/admin/src/pages/TeacherWorkspace/index.tsx b/apps/admin/src/pages/TeacherWorkspace/index.tsx new file mode 100644 index 0000000..286a04a --- /dev/null +++ b/apps/admin/src/pages/TeacherWorkspace/index.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import api from '../../api'; + +interface AssignedClass { + classId: number; + className: string; + classCode: string; + roleType: string; + subject: string; +} + +interface ScheduleItem { + id: number; + classId: number; + weekDay: number; + startTime: string; + endTime: string; + subject: string; + scheduleType: string; +} + +interface StudentItem { + studentId: number; + studentName: string; + studentNo: string; + className: string; + classId: number; + joinDate: string; +} + +interface WorkspaceData { + assignedClasses: AssignedClass[]; + todaySchedules: ScheduleItem[]; + myStudents: StudentItem[]; +} + +const ROLE_LABELS: Record = { + head_teacher: '班主任', + life_teacher: '生活老师', + academic_teacher: '教务老师', + subject_teacher: '任课教师', +}; + +const WEEKDAY_LABELS: Record = { + '1': '周一', + '2': '周二', + '3': '周三', + '4': '周四', + '5': '周五', + '6': '周六', + '7': '周日', +}; + +const TeacherWorkspacePage: React.FC = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const res = (await api.get('/rbac/teacher-workspace')) as WorkspaceData; + setData(res); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + fetchData(); + }, []); + + const classColumns: ColumnsType = [ + { + title: '班级名称', + dataIndex: 'className', + render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`, + }, + { + title: '角色', + dataIndex: 'roleType', + render: (v: string) => {ROLE_LABELS[v] || v}, + }, + { + title: '科目', + dataIndex: 'subject', + render: (v: string | null) => v || '-', + }, + ]; + + const scheduleColumns: ColumnsType = [ + { + title: '时间', + key: 'time', + render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`, + }, + { + title: '星期', + dataIndex: 'weekDay', + render: (v: number) => {WEEKDAY_LABELS[String(v)] || v}, + }, + { + title: '科目', + dataIndex: 'subject', + }, + { + title: '类型', + dataIndex: 'scheduleType', + render: (v: string) => ( + + {v === 'INTERNAL' ? '内部课程' : '租赁'} + + ), + }, + ]; + + const studentColumns: ColumnsType = [ + { title: '姓名', dataIndex: 'studentName' }, + { title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' }, + { title: '班级', dataIndex: 'className' }, + { title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' }, + ]; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + + + columns={classColumns} + dataSource={data.assignedClasses} + rowKey="classId" + pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个班级` }} + /> + ) : ( + + ), + }, + { + key: 'schedule', + label: `今日课程 (${data?.todaySchedules.length || 0})`, + children: data?.todaySchedules.length ? ( + + columns={scheduleColumns} + dataSource={data.todaySchedules} + rowKey="id" + pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 节` }} + /> + ) : ( + + ), + }, + { + key: 'students', + label: `我的学生 (${data?.myStudents.length || 0})`, + children: data?.myStudents.length ? ( + + columns={studentColumns} + dataSource={data.myStudents} + rowKey="studentId" + pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 人` }} + /> + ) : ( + + ), + }, + ]} + /> + + ); +}; + +export default TeacherWorkspacePage; diff --git a/apps/server/src/entities/user.entity.ts b/apps/server/src/entities/user.entity.ts index b326b3c..6b7f3f0 100644 --- a/apps/server/src/entities/user.entity.ts +++ b/apps/server/src/entities/user.entity.ts @@ -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', diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts index b3d4924..262ff38 100644 --- a/apps/server/src/rbac/dto/rbac.dto.ts +++ b/apps/server/src/rbac/dto/rbac.dto.ts @@ -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; +} diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index 5fe9019..3a45be2 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -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); + } } diff --git a/apps/server/src/rbac/rbac.module.ts b/apps/server/src/rbac/rbac.module.ts index 52f3316..dd27fe6 100644 --- a/apps/server/src/rbac/rbac.module.ts +++ b/apps/server/src/rbac/rbac.module.ts @@ -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], diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 6b6d4a8..cc82d9c 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -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, @InjectRepository(Role) private roleRepo: Repository, @InjectRepository(User) private userRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, ) {} async seedData(): Promise { @@ -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, + }; + } } diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index c54e3a5..3018560 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -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); + } } diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index 69a3837..2a9a8d9 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -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], diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 5e46432..6ee13b0 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -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) {} + + constructor( + @InjectRepository(Student) private repo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + ) {} 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(); + 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 }; + } }