From bce076ddafeb167476da5bde115237c0ee02bad9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 12 Aug 2026 06:04:13 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8F=AD=E7=BA=A7=E8=8A=B1=E5=90=8D?= =?UTF-8?q?=E5=86=8C=E4=B8=8E=E6=A1=A3=E6=A1=88=E6=8A=A5=E8=AF=BB=E5=8F=8C?= =?UTF-8?q?=E5=90=91=E5=90=8C=E6=AD=A5=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StudentProfileContent/EnrollmentsTab.tsx | 33 ++++++++- apps/admin/src/pages/Schedules/index.tsx | 4 ++ apps/admin/src/pages/Users/index.tsx | 2 + .../src/classes/class-roster-enrollment.ts | 70 +++++++++++++++++++ .../src/classes/classes-queries.service.ts | 9 +++ apps/server/src/classes/classes.module.ts | 4 +- apps/server/src/classes/classes.service.ts | 14 +++- 7 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/classes/class-roster-enrollment.ts diff --git a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx index 153e4b9..1018833 100644 --- a/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx +++ b/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx @@ -70,6 +70,10 @@ export const EnrollmentsTab: React.FC = async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`), { invalidate: [['archive', studentId]] }, ); + const archiveEnrollmentMutation = useApiMutation( + async (id: number) => api.delete(`/archive/enrollments/${id}`), + { invalidate: [['archive', studentId]] }, + ); const { data: classes = [], @@ -181,6 +185,23 @@ export const EnrollmentsTab: React.FC = } }; + const handleArchive = (record: EnrollmentRecord) => { + modal.confirm({ + title: `归档报读记录(${formatEnrollmentDisplayName(record)})?`, + content: '归档后该生将从对应班级花名册移出(若无其他在读报读),报读记录保留可查。', + okText: '归档', + cancelText: '取消', + onOk: async () => { + try { + await archiveEnrollmentMutation.mutateAsync(record.id); + message.success('已归档'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handlePurge = (record: EnrollmentRecord) => { modal.confirm({ title: `永久删除报读记录(${formatEnrollmentDisplayName(record)})?`, @@ -303,9 +324,15 @@ export const EnrollmentsTab: React.FC = { title: '操作', render: (_: unknown, r: EnrollmentRecord) => - r.status === 'archived' && canPurgeArchive ? ( - + ) + ) : hasPermission('student:edit') ? ( + ) : null, }, diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 08a43ee..30ef2a6 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -621,6 +621,8 @@ const SchedulesPage: React.FC = () => { mode="multiple" placeholder="筛选教室" allowClear + showSearch + optionFilterProp="label" style={{ minWidth: 200 }} value={filterClassroomIds} onChange={(v) => setFilterClassroomIds(v)} @@ -630,6 +632,8 @@ const SchedulesPage: React.FC = () => { r.status !== 0) .map((r: RoleOption) => ({ diff --git a/apps/server/src/classes/class-roster-enrollment.ts b/apps/server/src/classes/class-roster-enrollment.ts new file mode 100644 index 0000000..39040d2 --- /dev/null +++ b/apps/server/src/classes/class-roster-enrollment.ts @@ -0,0 +1,70 @@ +import { EntityManager, In } from 'typeorm'; +import { Class, ClassTeacher, StudentEnrollment } from '../entities'; + +/** + * 班级花名册 → 报读记录 单向补齐(双向同步的「班级侧添加」方向)。 + * + * 规则复刻前端「档案添加报读」的自动带出逻辑(EnrollmentsTab)与 + * archive.addEnrollment 的快照规则: + * - courseCategory = 班级 classType(culture/professional/comprehensive) + * - classType = 'offline'(与现有报读数据一致;班级无此字段) + * - className = 班级名快照 + * - headTeacher = 班级 head_teacher 角色老师名 + * - subjectTeacher = 班级 subject_teacher 角色老师名去重拼接 + * - startDate/endDate = 班级起止日期 + * - status = 'active' + * + * 幂等:该生已有 active 报读指向此班时跳过(与 syncRosterMembership 的 + * 「离班守卫」对称,防止重复建报读)。 + * + * ponytail: 不处理班级侧移除 → 报读联动;报读是档案数据,花名册移除不应 + * 隐式改动报读状态,需要时由用户显式在档案处理。 + */ +export async function ensureEnrollmentsForRoster( + manager: EntityManager, + classEntity: Class, + studentIds: number[], +): Promise { + if (studentIds.length === 0) return 0; + + const existing = await manager.find(StudentEnrollment, { + where: { classId: classEntity.id, studentId: In(studentIds), status: 'active' }, + select: ['studentId'], + }); + const existingSet = new Set(existing.map((e) => e.studentId)); + const missing = studentIds.filter((id) => !existingSet.has(id)); + if (missing.length === 0) return 0; + + const teachers = await manager.find(ClassTeacher, { + where: { classId: classEntity.id }, + relations: ['user'], + }); + const headTeacher = + teachers.find((t) => t.roleType === 'head_teacher')?.user?.name ?? undefined; + const subjectTeacher = + Array.from( + new Set( + teachers + .filter((t) => t.roleType === 'subject_teacher') + .map((t) => t.user?.name) + .filter((n): n is string => Boolean(n)), + ), + ).join('、') || undefined; + + const enrollments = missing.map((studentId) => + manager.create(StudentEnrollment, { + studentId, + classId: classEntity.id, + className: classEntity.name, + courseCategory: classEntity.classType, + classType: 'offline', + headTeacher, + subjectTeacher, + startDate: classEntity.startDate ?? undefined, + endDate: classEntity.endDate ?? undefined, + status: 'active', + }), + ); + await manager.save(StudentEnrollment, enrollments); + return enrollments.length; +} diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts index 7e73c30..58bb5c8 100644 --- a/apps/server/src/classes/classes-queries.service.ts +++ b/apps/server/src/classes/classes-queries.service.ts @@ -13,6 +13,7 @@ import { Classroom } from '../entities/classroom.entity'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; import { sanitizeCellText } from './class-roster-import'; +import { ensureEnrollmentsForRoster } from './class-roster-enrollment'; import type { ClassRosterCreateRow, ClassRosterImportRow } from './class-roster-import'; import { escapeLike } from '../common/like-escape'; import dayjs from '../common/dayjs'; @@ -791,6 +792,14 @@ export class ClassesQueriesService { } } } + // 双向同步:导入花名册 → 档案自动建报读(幂等,已有 active 报读则跳过) + if (memberships.length) { + await ensureEnrollmentsForRoster( + manager, + classEntity, + memberships.map((m) => m.studentId), + ); + } } } diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts index 856c2e3..a5ea0d6 100644 --- a/apps/server/src/classes/classes.module.ts +++ b/apps/server/src/classes/classes.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping } from '../entities'; +import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping, StudentEnrollment } from '../entities'; import { ClassesService } from './classes.service'; import { ClassesQueriesService } from './classes-queries.service'; import { ClassesController } from './classes.controller'; @@ -8,7 +8,7 @@ import { OperationLogsModule } from '../operation-logs/operation-logs.module'; import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule], + imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Exam, Student, StudentDingMapping, StudentEnrollment]), OperationLogsModule, NotificationsModule], controllers: [ClassesController], providers: [ClassesService, ClassesQueriesService], exports: [ClassesService], diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index f79013b..a209be7 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -20,6 +20,7 @@ import { StudentDingMapping, } from '../entities'; import { ClassesQueriesService, isDuplicateEntryError } from './classes-queries.service'; +import { ensureEnrollmentsForRoster } from './class-roster-enrollment'; import { normalizeDateOnly } from '../database/date-normalization'; import dayjs from '../common/dayjs'; import { @@ -442,9 +443,18 @@ export class ClassesService { }), ]; }); - if (memberships.length) await this.classStudentRepo.save(memberships); - return { added: memberships.length, skipped }; + return this.dataSource.transaction(async (manager) => { + let added = 0; + if (memberships.length) { + await manager.save(ClassStudent, memberships); + added = memberships.length; + } + // 双向同步:班级添加成员 → 档案自动建报读(幂等,已有 active 报读则跳过) + const enrolledIds = memberships.map((m) => m.studentId); + const enrollmentsCreated = await ensureEnrollmentsForRoster(manager, cls, enrolledIds); + return { added, skipped, enrollmentsCreated }; + }); } async removeStudent(classId: number, studentId: number) {