diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 4163de4..28881d2 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -24,6 +24,8 @@ const TeachersPage = lazy(() => import('./pages/Teachers')); const StudentProfilePage = lazy(() => import('./pages/StudentProfile')); const ClassesPage = lazy(() => import('./pages/Classes')); const ClassDetailPage = lazy(() => import('./pages/Classes/detail')); +const ExamsPage = lazy(() => import('./pages/Exams')); +const ExamDetailPage = lazy(() => import('./pages/Exams/detail')); const OrganizationsPage = lazy(() => import('./pages/Organizations')); const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals')); const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule')); @@ -174,6 +176,22 @@ const App: React.FC = () => { } /> + + + + } + /> + + + + } + /> { expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']); expect(paths.filter((path) => path === '/schedules')).toHaveLength(1); expect(paths.filter((path) => path === '/attendance')).toHaveLength(1); + expect(paths).toContain('/exams'); expect(paths).not.toContain('/teacher-workspace'); }); diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts index aa990b7..ceff6c5 100644 --- a/apps/admin/src/auth/menu-policy.ts +++ b/apps/admin/src/auth/menu-policy.ts @@ -61,6 +61,7 @@ const SECTIONS: MenuSection[] = [ children: [ { key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' }, { key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' }, + { key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' }, { key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' }, { key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' }, { key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' }, diff --git a/apps/admin/src/auth/permission-navigation.integration.test.ts b/apps/admin/src/auth/permission-navigation.integration.test.ts index 16882b8..2f77014 100644 --- a/apps/admin/src/auth/permission-navigation.integration.test.ts +++ b/apps/admin/src/auth/permission-navigation.integration.test.ts @@ -30,6 +30,7 @@ describe('permission navigation', () => { it('keeps route permission lookup aligned for nested detail routes', () => { expect(getRequiredPermission('/classes/12')).toBe('class:view'); expect(getRequiredPermission('/students/8/profile')).toBe('student:view'); + expect(getRequiredPermission('/exams/8')).toBe('exam:view'); expect(canAccessPath('/ai-config', ['ai:config:read'])).toBe(true); expect(canAccessPath('/ai-config', ['integration:read'])).toBe(false); }); diff --git a/apps/admin/src/auth/permission-navigation.ts b/apps/admin/src/auth/permission-navigation.ts index 6b09f63..5fe9b41 100644 --- a/apps/admin/src/auth/permission-navigation.ts +++ b/apps/admin/src/auth/permission-navigation.ts @@ -24,6 +24,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [ permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p), }, + { + path: '/exams', + permission: 'exam:view', + matches: (p) => p === '/exams' || /^\/exams\/\d+$/.test(p), + }, { path: '/attendance', permission: 'attendance:view' }, { path: '/schedules', permission: 'schedule:view' }, { path: '/classroom-schedule', permission: 'rental:view' }, diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 15d0b55..e86223b 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -81,10 +81,12 @@ interface EnrollmentRecord { interface ExamScoreRecord { id: number; + examId?: number; + exam?: { class?: { name?: string } }; examType: string; examName?: string; subject: string; - score: number; + score: number | null; classAvg?: number; rank?: number; examDate?: string; @@ -865,6 +867,7 @@ const ExamScoresTab: React.FC< editor="select" options={EXAM_TYPE_OPTIONS} permission="student:edit" + disabled={!!r.examId} required onSave={(next) => saveCell(r, 'examType', next)} > @@ -879,6 +882,7 @@ const ExamScoresTab: React.FC< saveCell(r, 'examName', next)} > {v || '-'} @@ -893,6 +897,7 @@ const ExamScoresTab: React.FC< value={v} required permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'subject', next)} > {v} @@ -902,16 +907,16 @@ const ExamScoresTab: React.FC< { title: '成绩', dataIndex: 'score', - render: (v: number, r) => ( + render: (v: number | null, r) => ( saveCell(r, 'score', next)} > - {v} + {v ?? '-'} ), }, @@ -924,6 +929,7 @@ const ExamScoresTab: React.FC< editor="number" min={0} permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'classAvg', next)} > {v !== undefined ? v : '-'} @@ -939,6 +945,7 @@ const ExamScoresTab: React.FC< editor="number" min={1} permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'rank', next)} > {v !== undefined ? v : '-'} @@ -953,6 +960,7 @@ const ExamScoresTab: React.FC< value={v} editor="date" permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'examDate', next)} > {v || '-'} @@ -971,9 +979,11 @@ const ExamScoresTab: React.FC< label: formatEnrollmentDisplayName(item), }))} permission="student:edit" + disabled={!!r.examId} onSave={(next) => saveCell(r, 'enrollmentId', next)} > {(() => { + if (r.examId) return r.exam?.class?.name || '-'; if (v === undefined) return '-'; const enr = enrollments.find((e) => e.id === v); return enr ? formatEnrollmentDisplayName(enr) : String(v); diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 54c5e33..495e6cb 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -25,6 +25,7 @@ import { CheckCircleOutlined, LaptopOutlined, BellOutlined, + TrophyOutlined, ApiOutlined, RobotOutlined, } from '@ant-design/icons'; @@ -46,6 +47,7 @@ const iconMap: Record = { students: , classes: , teachers: , + exam: , home: , overview: , occupancy: , diff --git a/apps/admin/src/pages/Exams/ExamFormModal.tsx b/apps/admin/src/pages/Exams/ExamFormModal.tsx new file mode 100644 index 0000000..6f60b64 --- /dev/null +++ b/apps/admin/src/pages/Exams/ExamFormModal.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { DatePicker, Form, Input, Modal, Select } from 'antd'; +import type { FormInstance } from 'antd'; +import type { ClassOption, ExamFormValues } from './types'; +import { EXAM_TYPE_OPTIONS } from './types'; + +interface Props { + open: boolean; + editing: boolean; + saving: boolean; + form: FormInstance; + classes: ClassOption[]; + onCancel: () => void; + onSubmit: () => void; +} + +const ExamFormModal: React.FC = ({ + open, + editing, + saving, + form, + classes, + onCancel, + onSubmit, +}) => ( + +
+ + + + + + + + + + + setKeyword(event.target.value)} prefix={} placeholder="搜索考试名称" allowClear /> + + + + + + + + {data.length === 0 && !loading ? ( +
+ ) : ( + + {data.map((exam) => { + const percent = exam.totalStudents === 0 ? 0 : Math.round((exam.enteredScores / exam.totalStudents) * 100); + return ( + + {exam.examType}{exam.examName}} + extra={成绩录入} + actions={[ + navigate(`/exams/${exam.id}`)}>查看成绩, + ]} + > +
科目{exam.subject}
+
班级{exam.className}
+
日期{exam.examDate}
+
成绩录入{exam.enteredScores}/{exam.totalStudents}
+
+ + ); + })} +
+ )} + + setModalOpen(false)} onSubmit={() => void submit()} /> + + ); +}; + +export default ExamsPage; diff --git a/apps/admin/src/pages/Exams/style.css b/apps/admin/src/pages/Exams/style.css new file mode 100644 index 0000000..3428cc3 --- /dev/null +++ b/apps/admin/src/pages/Exams/style.css @@ -0,0 +1,77 @@ +.exam-page, +.exam-detail-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.exam-toolbar, +.exam-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.exam-detail-header h2 { + margin: 0; + font-size: 20px; + letter-spacing: 0; +} + +.exam-card { + height: 100%; + border-radius: 8px; +} + +.exam-card .ant-card-head-title { + min-width: 0; +} + +.exam-card .ant-card-head-title > .ant-space { + max-width: 100%; +} + +.exam-card .ant-card-head-title span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.exam-meta, +.exam-progress > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; +} + +.exam-meta span, +.exam-progress span { + color: rgba(0, 0, 0, 0.55); +} + +.exam-progress { + margin-top: 16px; +} + +.exam-empty, +.exam-detail-loading { + min-height: 360px; + display: grid; + place-items: center; +} + +.exam-summary { + border-radius: 8px; +} + +@media (max-width: 575px) { + .exam-toolbar > .ant-space, + .exam-toolbar .ant-input-affix-wrapper, + .exam-toolbar .ant-select { + width: 100% !important; + } +} diff --git a/apps/admin/src/pages/Exams/types.ts b/apps/admin/src/pages/Exams/types.ts new file mode 100644 index 0000000..f2e94b2 --- /dev/null +++ b/apps/admin/src/pages/Exams/types.ts @@ -0,0 +1,37 @@ +import type dayjs from 'dayjs'; + +export interface ExamItem { + id: number; + examType: string; + examName: string; + subject: string; + examDate: string; + classId: number; + className: string; + status: 'active' | 'archived'; + totalStudents: number; + enteredScores: number; +} + +export interface ExamFormValues { + examType: string; + examName: string; + subject: string; + examDate: dayjs.Dayjs; + classId: number; +} + +export interface ClassOption { + id: number; + name: string; + isArchived: boolean; +} + +export const EXAM_TYPE_OPTIONS = [ + { value: '月考', label: '月考' }, + { value: '周测', label: '周测' }, + { value: '期中考试', label: '期中考试' }, + { value: '期末考试', label: '期末考试' }, + { value: '模拟考试', label: '模拟考试' }, + { value: '入学测试', label: '入学测试' }, +]; diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index a09f1b6..2c3efe9 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -39,6 +39,7 @@ import { StudentProfile, StudentEnrollment, ExamScore, + Exam, LearningRecord, ExpenseType, ResultArchive, @@ -51,7 +52,8 @@ import { } from './entities'; import { AuthModule } from './auth/auth.module'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; -const allMigrations = [InitialSchema1784520727860]; +import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; +const allMigrations = [InitialSchema1784520727860, AddExamManagement1784600000000]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; import { StudentsModule } from './students/students.module'; @@ -81,6 +83,7 @@ import { AgentToolsModule } from './agent-tools'; import { AiConfigModule } from './ai-config/ai-config.module'; import { WalletsModule } from './wallets/wallets.module'; import { FinancialOperationsModule } from './financial-operations/financial-operations.module'; +import { ExamsModule } from './exams/exams.module'; import { IntegrationConfig, @@ -137,6 +140,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; StudentProfile, StudentEnrollment, ExamScore, + Exam, LearningRecord, ExpenseType, ArchiveAttachment, @@ -178,6 +182,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; AuthModule, RbacModule, StudentsModule, + ExamsModule, RoomsModule, OccupanciesModule, ExpensesModule, diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index e493754..312ca0a 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -412,11 +412,13 @@ ${this.buildLearningAndResult(learnings, result, now)} const highestName = highestExam?.examName ?? '-'; // Improvement: last exam score minus first exam score - const sortedExams = [...cultureExams].filter((e) => e.score != null); + const sortedScores = cultureExams + .map((exam) => exam.score) + .filter((score): score is number => score !== null && score !== undefined); let improvement = '—'; - if (sortedExams.length >= 2) { - const first = sortedExams[0].score; - const last = sortedExams[sortedExams.length - 1].score; + if (sortedScores.length >= 2) { + const first = sortedScores[0]; + const last = sortedScores[sortedScores.length - 1]; improvement = (last - first).toFixed(1); } @@ -511,7 +513,7 @@ ${this.buildLearningAndResult(learnings, result, now)} const cultureExams = exams.filter((e) => e.score != null); if (cultureExams.length === 0) return ''; - const scores = cultureExams.map((e) => e.score); + const scores = cultureExams.map((e) => Number(e.score)); const labels = cultureExams.map((e) => { const d = e.examDate || '-'; return d.length > 7 ? d.slice(5) : d; diff --git a/apps/server/src/archive/archive.boundaries.spec.ts b/apps/server/src/archive/archive.boundaries.spec.ts index 13ed5e7..e4b3fd3 100644 --- a/apps/server/src/archive/archive.boundaries.spec.ts +++ b/apps/server/src/archive/archive.boundaries.spec.ts @@ -67,6 +67,22 @@ describe('ArchiveService — resource and relationship boundaries', () => { expect(exam.save).not.toHaveBeenCalled(); }); + it('keeps exam-management scores read-only in the student archive', async () => { + const exam = { + findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, examId: 8 }), + save: jest.fn(), + update: jest.fn(), + }; + const service = createService({ exam }); + + await expect(service.updateExamScore(3, { score: 95 })).rejects.toBeInstanceOf( + BadRequestException, + ); + await expect(service.deleteExamScore(3)).rejects.toBeInstanceOf(BadRequestException); + expect(exam.save).not.toHaveBeenCalled(); + expect(exam.update).not.toHaveBeenCalled(); + }); + it('rejects a missing attachment upload before writing to disk', async () => { const service = createService({ student: { findOne: jest.fn() } }); await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf( diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index f1140c2..b6eb6cd 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -75,7 +75,11 @@ export class ArchiveService { ] = await Promise.all([ this.profileRepo.findOne({ where: { studentId } }), this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), - this.examScoreRepo.find({ where: { studentId, status: 'active' }, order: { examDate: 'DESC' } }), + this.examScoreRepo.find({ + where: { studentId, status: 'active' }, + relations: ['exam', 'exam.class'], + order: { examDate: 'DESC' }, + }), this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }), this.resultRepo.findOne({ where: { studentId } }), this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), @@ -154,6 +158,7 @@ export class ArchiveService { async updateExamScore(id: number, dto: UpdateExamScoreDto) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); + if (entity.examId) throw new BadRequestException('考试管理同步成绩请在考试管理中修改'); await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId); Object.assign(entity, dto); return this.examScoreRepo.save(entity); @@ -162,6 +167,7 @@ export class ArchiveService { async deleteExamScore(id: number) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); + if (entity.examId) throw new BadRequestException('考试管理同步成绩不能在学生档案中归档'); if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档'); await this.examScoreRepo.update(id, { status: 'archived' }); return { message: '已归档' }; diff --git a/apps/server/src/entities/exam-score.entity.ts b/apps/server/src/entities/exam-score.entity.ts index 4a88ecf..4a6e3e9 100644 --- a/apps/server/src/entities/exam-score.entity.ts +++ b/apps/server/src/entities/exam-score.entity.ts @@ -9,12 +9,20 @@ import { } from 'typeorm'; import { Student } from './student.entity'; import { StudentEnrollment } from './student-enrollment.entity'; +import { Exam } from './exam.entity'; @Entity('exam_scores') export class ExamScore { @PrimaryGeneratedColumn() id: number; + @Column({ name: 'exam_id', type: 'integer', nullable: true }) + examId: number | null; + + @ManyToOne(() => Exam, (exam) => exam.scores, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'exam_id' }) + exam: Exam | null; + @Column({ name: 'student_id', type: 'integer' }) studentId: number; @@ -39,13 +47,13 @@ export class ExamScore { subject: string; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) - score: number; + score: number | null; @Column({ name: 'class_avg', type: 'decimal', precision: 5, scale: 2, nullable: true }) - classAvg: number; + classAvg: number | null; @Column({ type: 'integer', nullable: true }) - rank: number; + rank: number | null; @Column({ name: 'exam_date', type: 'date', nullable: true }) examDate: string; diff --git a/apps/server/src/entities/exam.entity.ts b/apps/server/src/entities/exam.entity.ts new file mode 100644 index 0000000..47de60d --- /dev/null +++ b/apps/server/src/entities/exam.entity.ts @@ -0,0 +1,49 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Class } from './class.entity'; +import { ExamScore } from './exam-score.entity'; + +@Entity('exams') +export class Exam { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'exam_type', length: 50 }) + examType: string; + + @Column({ name: 'exam_name', length: 100 }) + examName: string; + + @Column({ length: 50 }) + subject: string; + + @Column({ name: 'exam_date', type: 'date' }) + examDate: string; + + @Column({ name: 'class_id', type: 'integer' }) + classId: number; + + @ManyToOne(() => Class) + @JoinColumn({ name: 'class_id' }) + class: Class; + + @OneToMany(() => ExamScore, (score) => score.exam) + scores: ExamScore[]; + + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: 'active' | 'archived'; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 8b5cba5..9e85eae 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -32,6 +32,7 @@ export { Notification, NotificationType } from './notification.entity'; export { StudentProfile } from './student-profile.entity'; export { StudentEnrollment } from './student-enrollment.entity'; export { ExamScore } from './exam-score.entity'; +export { Exam } from './exam.entity'; export { LearningRecord } from './learning-record.entity'; export { ResultArchive } from './result-archive.entity'; export { ArchiveAttachment } from './archive-attachment.entity'; diff --git a/apps/server/src/exams/dto/exam.dto.ts b/apps/server/src/exams/dto/exam.dto.ts new file mode 100644 index 0000000..5747a9b --- /dev/null +++ b/apps/server/src/exams/dto/exam.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { + IsDateString, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class CreateExamDto { + @IsString() @IsNotEmpty() examType: string; + @IsString() @IsNotEmpty() examName: string; + @IsString() @IsNotEmpty() subject: string; + @IsDateString() examDate: string; + @IsInt() @Min(1) classId: number; +} + +export class QueryExamDto { + @IsOptional() @IsString() keyword?: string; + @IsOptional() @IsString() examType?: string; + @IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number; +} + +export class UpdateExamScoreValueDto { + @IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null; +} diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts new file mode 100644 index 0000000..aae912f --- /dev/null +++ b/apps/server/src/exams/exams.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + Put, + Query, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequirePermission } from '../auth/decorators/permission.decorator'; +import { extractRequestInfo } from '../common/request-utils'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import type { AuthenticatedUser } from '../authorization'; +import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto'; +import { ExamsService } from './exams.service'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} + +@UseGuards(JwtAuthGuard) +@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) +@Controller('exams') +export class ExamsController { + constructor( + private readonly service: ExamsService, + private readonly logService: OperationLogsService, + ) {} + + private canManageAll(req: AuthenticatedRequest) { + return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit'); + } + + @Get() + @RequirePermission('exam:view') + async findAll(@Query() query: QueryExamDto, @Request() req: AuthenticatedRequest) { + const classIds = await this.service.getAccessibleClassIds(req.user.id, this.canManageAll(req)); + return this.service.findAll(query, classIds); + } + + @Get(':id') + @RequirePermission('exam:view') + findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { + return this.service.findOne(id, req.user.id, this.canManageAll(req)); + } + + @Post() + @RequirePermission('exam:view') + async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { + const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考试管理', + action: '创建考试', + targetId: result.id, + targetType: 'exam', + detail: `${dto.examName} - ${dto.subject}`, + ipAddress, + userAgent, + }); + return result; + } + + @Put(':examId/scores/:scoreId') + @RequirePermission('exam:view') + async updateScore( + @Param('examId', ParseIntPipe) examId: number, + @Param('scoreId', ParseIntPipe) scoreId: number, + @Body() dto: UpdateExamScoreValueDto, + @Request() req: AuthenticatedRequest, + ) { + const result = await this.service.updateScore( + examId, + scoreId, + dto.score, + req.user.id, + this.canManageAll(req), + ); + const { ipAddress, userAgent } = extractRequestInfo(req); + await this.logService.log({ + userId: req.user.id, + username: req.user.username, + module: '考试管理', + action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', + targetId: scoreId, + targetType: 'exam_score', + detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`, + ipAddress, + userAgent, + }); + return result; + } + +} diff --git a/apps/server/src/exams/exams.module.ts b/apps/server/src/exams/exams.module.ts new file mode 100644 index 0000000..b48ba04 --- /dev/null +++ b/apps/server/src/exams/exams.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Class, ClassStudent, ClassTeacher, Exam, ExamScore, Student } from '../entities'; +import { OperationLogsModule } from '../operation-logs/operation-logs.module'; +import { ExamsController } from './exams.controller'; +import { ExamsService } from './exams.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Exam, ExamScore, Class, ClassStudent, ClassTeacher, Student]), + OperationLogsModule, + ], + controllers: [ExamsController], + providers: [ExamsService], +}) +export class ExamsModule {} diff --git a/apps/server/src/exams/exams.service.spec.ts b/apps/server/src/exams/exams.service.spec.ts new file mode 100644 index 0000000..2547127 --- /dev/null +++ b/apps/server/src/exams/exams.service.spec.ts @@ -0,0 +1,127 @@ +import { BadRequestException } from '@nestjs/common'; +import { ExamScore } from '../entities'; +import { ExamsService } from './exams.service'; + +function createService(transaction: (run: (manager: any) => Promise) => Promise) { + return new ExamsService( + {} as never, + {} as never, + {} as never, + {} as never, + { findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never, + { transaction } as never, + ); +} + +describe('ExamsService', () => { + it('creates score rows from the active class roster snapshot', async () => { + const members = [ + { studentId: 11, status: 'active' }, + { studentId: 12, status: 'active' }, + ]; + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }), + find: jest.fn().mockResolvedValue(members), + create: jest.fn((_entity, value) => value), + save: jest.fn(async (entity, value) => + entity.name === 'Exam' ? { ...value, id: 9 } : value, + ), + }; + const service = createService(async (run) => run(manager)); + + await service.create( + { + examType: '月考', + examName: '七月月考', + subject: '数学', + examDate: '2026-07-21', + classId: 3, + }, + 1, + true, + ); + + expect(manager.find).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ where: { classId: 3, status: 'active' } }), + ); + expect(manager.save).toHaveBeenCalledWith( + ExamScore, + expect.arrayContaining([ + expect.objectContaining({ examId: 9, studentId: 11, score: null }), + expect.objectContaining({ examId: 9, studentId: 12, score: null }), + ]), + ); + }); + + it('rejects creating an exam for an empty class', async () => { + const manager = { + findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }), + find: jest.fn().mockResolvedValue([]), + }; + const service = createService(async (run) => run(manager)); + + await expect( + service.create( + { + examType: '月考', + examName: '七月月考', + subject: '数学', + examDate: '2026-07-21', + classId: 3, + }, + 1, + true, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('counts zero, ignores empty scores, and uses competition ranking', async () => { + const rows = [ + { id: 1, score: 90, classAvg: null, rank: null }, + { id: 2, score: 90, classAvg: null, rank: null }, + { id: 3, score: 60, classAvg: null, rank: null }, + { id: 4, score: 0, classAvg: null, rank: null }, + { id: 5, score: null, classAvg: null, rank: null }, + ]; + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' }) + .mockResolvedValueOnce(rows[0]) + .mockResolvedValueOnce(rows[0]), + find: jest.fn().mockResolvedValue(rows), + save: jest.fn(async (_entity, value) => value), + }; + const service = createService(async (run) => run(manager)); + + await service.updateScore(8, 1, 90, 1, true); + + expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]); + expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]); + }); + + it('clears a score and recalculates the remaining rows', async () => { + const rows = [ + { id: 1, score: 90, classAvg: 80, rank: 1 }, + { id: 2, score: 70, classAvg: 80, rank: 2 }, + ]; + const manager = { + findOne: jest + .fn() + .mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' }) + .mockResolvedValueOnce(rows[0]) + .mockResolvedValueOnce(rows[0]), + find: jest.fn().mockResolvedValue(rows), + save: jest.fn(async (_entity, value) => value), + }; + const service = createService(async (run) => run(manager)); + + await service.updateScore(8, 1, null, 1, true); + + expect(rows).toEqual([ + expect.objectContaining({ score: null, classAvg: 70, rank: null }), + expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }), + ]); + }); +}); diff --git a/apps/server/src/exams/exams.service.ts b/apps/server/src/exams/exams.service.ts new file mode 100644 index 0000000..aaa3aef --- /dev/null +++ b/apps/server/src/exams/exams.service.ts @@ -0,0 +1,182 @@ +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In, Like, Repository } from 'typeorm'; +import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities'; +import { CreateExamDto, QueryExamDto } from './dto/exam.dto'; + +@Injectable() +export class ExamsService { + constructor( + @InjectRepository(Exam) private examRepo: Repository, + @InjectRepository(ExamScore) private scoreRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + private dataSource: DataSource, + ) {} + + async getAccessibleClassIds(userId: number, canManageAll: boolean) { + if (canManageAll) return undefined; + const rows = await this.classTeacherRepo.find({ where: { userId } }); + return [...new Set(rows.map((row) => row.classId))]; + } + + async assertClassAccess(userId: number, classId: number, canManageAll: boolean) { + if (canManageAll) return; + const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } }); + if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级'); + } + + async findAll(query: QueryExamDto, accessibleClassIds?: number[]) { + const where: Record = { + status: 'active', + }; + if (query.keyword) where.examName = Like(`%${query.keyword}%`); + if (query.examType) where.examType = query.examType; + if (query.classId) where.classId = query.classId; + if (accessibleClassIds) { + if (accessibleClassIds.length === 0) return []; + where.classId = query.classId + ? accessibleClassIds.includes(query.classId) + ? query.classId + : -1 + : In(accessibleClassIds); + } + + const exams = await this.examRepo.find({ + where, + relations: ['class'], + order: { examDate: 'DESC', createdAt: 'DESC' }, + }); + if (exams.length === 0) return []; + const scoreRows = await this.scoreRepo.find({ + where: { examId: In(exams.map((exam) => exam.id)), status: 'active' }, + }); + const progress = new Map(); + for (const row of scoreRows) { + const item = progress.get(row.examId!) ?? { total: 0, entered: 0 }; + item.total++; + if (row.score !== null && row.score !== undefined) item.entered++; + progress.set(row.examId!, item); + } + return exams.map((exam) => ({ + ...exam, + className: exam.class?.name, + totalStudents: progress.get(exam.id)?.total ?? 0, + enteredScores: progress.get(exam.id)?.entered ?? 0, + })); + } + + async findOne(id: number, userId: number, canManageAll: boolean) { + const exam = await this.examRepo.findOne({ where: { id }, relations: ['class'] }); + if (!exam) throw new NotFoundException('考试不存在'); + await this.assertClassAccess(userId, exam.classId, canManageAll); + const scores = await this.scoreRepo.find({ + where: { examId: id, status: 'active' }, + relations: ['student'], + order: { id: 'ASC' }, + }); + return { + ...exam, + className: exam.class?.name, + totalStudents: scores.length, + enteredScores: scores.filter((row) => row.score !== null && row.score !== undefined).length, + scores: scores.map((row) => ({ + id: row.id, + studentId: row.studentId, + phone: row.student?.phone, + name: row.student?.name, + score: row.score === null ? null : Number(row.score), + classAvg: row.classAvg === null ? null : Number(row.classAvg), + rank: row.rank, + })), + }; + } + + async create(dto: CreateExamDto, userId: number, canManageAll: boolean) { + await this.assertClassAccess(userId, dto.classId, canManageAll); + return this.dataSource.transaction(async (manager) => { + const cls = await manager.findOne(Class, { where: { id: dto.classId } }); + if (!cls) throw new NotFoundException('班级不存在'); + if (cls.isArchived) throw new BadRequestException('归档班级不能创建考试'); + const members = await manager.find(ClassStudent, { + where: { classId: dto.classId, status: 'active' }, + order: { createdAt: 'ASC' }, + }); + if (members.length === 0) throw new BadRequestException('班级暂无在读学员,不能创建考试'); + const exam = await manager.save(Exam, manager.create(Exam, { ...dto, status: 'active' })); + await this.createScoreRows(manager, exam, members); + return exam; + }); + } + + async updateScore( + examId: number, + scoreId: number, + score: number | null | undefined, + userId: number, + canManageAll: boolean, + ) { + return this.dataSource.transaction(async (manager) => { + const exam = await manager.findOne(Exam, { where: { id: examId } }); + if (!exam) throw new NotFoundException('考试不存在'); + await this.assertClassAccess(userId, exam.classId, canManageAll); + if (exam.status === 'archived') throw new BadRequestException('已归档考试不能录入成绩'); + const row = await manager.findOne(ExamScore, { where: { id: scoreId, examId } }); + if (!row) throw new NotFoundException('成绩记录不存在'); + row.score = score === undefined ? null : score; + await manager.save(ExamScore, row); + await this.recalculate(manager, examId); + return manager.findOne(ExamScore, { where: { id: scoreId } }); + }); + } + + private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) { + const rows = members.map((member) => + manager.create(ExamScore, { + examId: exam.id, + studentId: member.studentId, + examType: exam.examType, + examName: exam.examName, + subject: exam.subject, + score: null, + classAvg: null, + rank: null, + examDate: exam.examDate, + status: 'active', + }), + ); + await manager.save(ExamScore, rows); + } + + private async recalculate(manager: EntityManager, examId: number) { + const rows = await manager.find(ExamScore, { where: { examId, status: 'active' } }); + const entered = rows + .filter((row) => row.score !== null && row.score !== undefined) + .sort((a, b) => Number(b.score) - Number(a.score)); + const average = + entered.length === 0 + ? null + : Math.round((entered.reduce((sum, row) => sum + Number(row.score), 0) / entered.length) * 100) / + 100; + let previousScore: number | null = null; + let previousRank = 0; + for (let index = 0; index < entered.length; index++) { + const row = entered[index]; + const value = Number(row.score); + const rank = previousScore !== null && value === previousScore ? previousRank : index + 1; + row.classAvg = average; + row.rank = rank; + previousScore = value; + previousRank = rank; + } + const enteredIds = new Set(entered.map((row) => row.id)); + for (const row of rows) { + if (!enteredIds.has(row.id)) { + row.classAvg = average; + row.rank = null; + } + } + await manager.save(ExamScore, rows); + } +} diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index e0ff8d7..e8e6bb3 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -1,5 +1,6 @@ import { DataSource } from 'typeorm'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; +import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; import { config } from 'dotenv'; config(); @@ -18,7 +19,7 @@ export async function runMigrationsOnStartup(): Promise { password: process.env.DB_PASSWORD || '', database: process.env.DB_DATABASE || 'dorm_billing', charset: 'utf8mb4', - migrations: [InitialSchema1784520727860], + migrations: [InitialSchema1784520727860, AddExamManagement1784600000000], }); await ds.initialize(); diff --git a/apps/server/src/migrations/1784600000000-AddExamManagement.ts b/apps/server/src/migrations/1784600000000-AddExamManagement.ts new file mode 100644 index 0000000..b4cf48d --- /dev/null +++ b/apps/server/src/migrations/1784600000000-AddExamManagement.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from 'typeorm'; + +export class AddExamManagement1784600000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasTable('exams'))) { + await queryRunner.createTable( + new Table({ + name: 'exams', + columns: [ + { name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' }, + { name: 'exam_type', type: 'varchar', length: '50' }, + { name: 'exam_name', type: 'varchar', length: '100' }, + { name: 'subject', type: 'varchar', length: '50' }, + { name: 'exam_date', type: 'date' }, + { name: 'class_id', type: 'integer' }, + { name: 'status', type: 'varchar', length: '20', default: "'active'" }, + { name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' }, + ], + }), + ); + await queryRunner.createForeignKey( + 'exams', + new TableForeignKey({ + columnNames: ['class_id'], + referencedTableName: 'classes', + referencedColumnNames: ['id'], + }), + ); + await queryRunner.createIndex('exams', new TableIndex({ columnNames: ['class_id', 'status'] })); + } + + if (!(await queryRunner.hasColumn('exam_scores', 'exam_id'))) { + await queryRunner.addColumn( + 'exam_scores', + new TableColumn({ name: 'exam_id', type: 'integer', isNullable: true }), + ); + await queryRunner.createForeignKey( + 'exam_scores', + new TableForeignKey({ + columnNames: ['exam_id'], + referencedTableName: 'exams', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + await queryRunner.createIndex('exam_scores', new TableIndex({ columnNames: ['exam_id'] })); + } + } + + async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn('exam_scores', 'exam_id')) { + const table = await queryRunner.getTable('exam_scores'); + const foreignKey = table?.foreignKeys.find((key) => key.columnNames.includes('exam_id')); + if (foreignKey) await queryRunner.dropForeignKey('exam_scores', foreignKey); + const index = table?.indices.find((item) => item.columnNames.includes('exam_id')); + if (index) await queryRunner.dropIndex('exam_scores', index); + await queryRunner.dropColumn('exam_scores', 'exam_id'); + } + if (await queryRunner.hasTable('exams')) await queryRunner.dropTable('exams'); + } +} diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index a76dd6a..a814e61 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -26,6 +26,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'student:delete', name: '归档学生', group: 'student' }, { code: 'student:import', name: '导入学生', group: 'student' }, { code: 'student:export', name: '导出学生', group: 'student' }, + { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, { code: 'room:view', name: '查看宿舍', group: 'room' }, { code: 'room:create', name: '新增宿舍', group: 'room' }, { code: 'room:edit', name: '编辑宿舍', group: 'room' }, @@ -203,6 +204,7 @@ export const PRESET_ROLES: Array<{ isSystem: true, permissionGroups: [ 'student', + 'exam', 'class', 'schedule', 'attendance',