diff --git a/src/Jiaowu.Api/Controllers/AttendanceController.cs b/src/Jiaowu.Api/Controllers/AttendanceController.cs index 3c25a38..676dec1 100644 --- a/src/Jiaowu.Api/Controllers/AttendanceController.cs +++ b/src/Jiaowu.Api/Controllers/AttendanceController.cs @@ -44,7 +44,8 @@ public sealed class AttendanceController( page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 50); var tasks = AccessibleTasks().AsNoTracking() - .Where(x => x.Status == TeachingTaskStatus.Published); + .Where(x => x.Status == TeachingTaskStatus.Published || + x.Status == TeachingTaskStatus.Closed); if (academicTermId.HasValue) tasks = tasks.Where(x => x.AcademicTermId == academicTermId); if (!string.IsNullOrWhiteSpace(keyword)) @@ -73,6 +74,7 @@ public sealed class AttendanceController( .Select(x => new { x.Id, + x.Status, x.TaskNumber, x.Name, x.AcademicTermId, @@ -162,6 +164,8 @@ public sealed class AttendanceController( var task = await AccessibleTasks().AsNoTracking() .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); if (task is null) return NotFound(); + if (!IsTaskOperable(task)) + return ConflictProblem("教学任务已归档,不能新建考勤表。"); var studentIds = await TeachingTaskRosterQuery .ForTask(db, request.TeachingTaskId) @@ -258,6 +262,7 @@ public sealed class AttendanceController( x.LocationRadiusMeters, x.Notes, x.SubmittedAt, + TaskStatus = x.TeachingTask!.Status, TaskNumber = x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, CourseCode = x.TeachingTask.Course!.Code, @@ -295,7 +300,8 @@ public sealed class AttendanceController( var canManage = await CanManageTaskAsync( sheet.TeachingTaskId, cancellationToken); - var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage; + var canEdit = sheet.Status == AttendanceSheetStatus.Draft && + sheet.TaskStatus == TeachingTaskStatus.Published && canManage; var now = DateTime.UtcNow; var attempts = await db.AttendanceCheckInAttempts.AsNoTracking() .Where(x => x.AttendanceSheetId == id) @@ -758,6 +764,7 @@ public sealed class AttendanceController( .Where(x => x.StudentId == studentId.Value && x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft && + x.AttendanceSheet.TeachingTask!.Status == TeachingTaskStatus.Published && x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location && x.AttendanceSheet.CheckInStartsAt <= now && x.AttendanceSheet.CheckInEndsAt >= now) @@ -791,6 +798,7 @@ public sealed class AttendanceController( var source = db.AttendanceRecords .Include(x => x.AttendanceSheet) + .ThenInclude(x => x!.TeachingTask) .Where(x => x.StudentId == studentId.Value); if (!string.IsNullOrWhiteSpace(request.Token)) { @@ -813,6 +821,8 @@ public sealed class AttendanceController( var record = await source.FirstOrDefaultAsync(cancellationToken); if (record?.AttendanceSheet is null) return NotFound(); var sheet = record.AttendanceSheet; + if (!IsTaskOperable(sheet.TeachingTask!)) + return ConflictProblem("教学任务已归档,签到活动已停止。"); var now = DateTime.UtcNow; async Task RejectAttemptAsync( @@ -1011,11 +1021,15 @@ public sealed class AttendanceController( return ConflictProblem("当前账号未关联学生档案。"); var record = await db.AttendanceRecords + .Include(r => r.AttendanceSheet) + .ThenInclude(s => s!.TeachingTask) .FirstOrDefaultAsync(r => r.AttendanceSheetId == request.AttendanceSheetId && r.StudentId == studentId.Value, cancellationToken); if (record is null) return NotFound(); + if (!IsTaskOperable(record.AttendanceSheet!.TeachingTask!)) + return ConflictProblem("教学任务已归档,不能提交考勤申诉。"); if (record.AppealStatus == AttendanceAppealStatus.Pending) return ConflictProblem("已有申诉正在处理中。"); if (record.AppealStatus == AttendanceAppealStatus.Approved) @@ -1202,10 +1216,14 @@ public sealed class AttendanceController( } private bool CanManageSheet(AttendanceSheet sheet) => - currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + IsTaskOperable(sheet.TeachingTask!) && + (currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || sheet.TeachingTask!.Teachers.Any(x => - x.Teacher?.UserId == currentUserDataScope.Current.UserId); + x.Teacher?.UserId == currentUserDataScope.Current.UserId)); + + private static bool IsTaskOperable(TeachingTask task) => + task.Status == TeachingTaskStatus.Published; private async Task CanManageTaskAsync( Guid teachingTaskId, diff --git a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs index 363567f..5bb9da2 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentGradesController.cs @@ -89,6 +89,7 @@ public sealed class ExperimentGradesController( .GroupBy(x => new { x.TeachingTaskId, + TaskStatus = x.TeachingTask!.Status, StartDate = x.TeachingTask!.AcademicTerm!.StartDate, x.TeachingTask.TaskNumber }) @@ -114,6 +115,7 @@ public sealed class ExperimentGradesController( x.ScheduleWeek, ProjectStatus = x.Status, x.TeachingTaskId, + TaskStatus = x.TeachingTask!.Status, x.TeachingTask!.TaskNumber, TaskName = x.TeachingTask.Name, AcademicTermId = x.TeachingTask.AcademicTermId, @@ -567,7 +569,8 @@ public sealed class ExperimentGradesController( .SingleAsync( x => x.Id == sheet.TeachingTaskId, cancellationToken); - var canReview = CanReviewCollege(sheet.CourseCollegeId) && + var isTaskOperable = IsTaskOperable(task); + var canReview = isTaskOperable && CanReviewCollege(sheet.CourseCollegeId) && sheet.Status == ExperimentGradeSheetStatus.Submitted; return Ok(new @@ -603,12 +606,12 @@ public sealed class ExperimentGradesController( sheet.CreatedAt, sheet.UpdatedAt }, - CanEdit = (CanEdit(task) || isBoundInstructor) && + CanEdit = isTaskOperable && (CanEdit(task) || isBoundInstructor) && sheet.Status is ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned, CanReview = canReview, - CanPublish = IsPublisher() && + CanPublish = isTaskOperable && IsPublisher() && sheet.Status == ExperimentGradeSheetStatus.Approved }); } @@ -1074,6 +1077,8 @@ public sealed class ExperimentGradesController( { var sheet = await LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); + if (!IsTaskOperable(sheet.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId; if (!CanReviewCollege(collegeId)) return Forbid(); if (sheet.Status != ExperimentGradeSheetStatus.Submitted) @@ -1100,6 +1105,8 @@ public sealed class ExperimentGradesController( { var sheet = await LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); + if (!IsTaskOperable(sheet.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId; if (!CanReviewCollege(collegeId)) return Forbid(); if (sheet.Status != ExperimentGradeSheetStatus.Submitted) @@ -1127,6 +1134,8 @@ public sealed class ExperimentGradesController( { var sheet = await LoadForWorkflowAsync(id, cancellationToken); if (sheet is null) return NotFound(); + if (!IsTaskOperable(sheet.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); if (!IsPublisher()) return Forbid(); if (sheet.Status != ExperimentGradeSheetStatus.Approved) return ConflictProblem("只有审核通过的实验成绩单可以发布。"); @@ -1291,21 +1300,23 @@ public sealed class ExperimentGradesController( } private bool CanInitialize(TeachingTask task) => - currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + IsTaskOperable(task) && + (currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) || - IsAssignedTeacher(task); + IsAssignedTeacher(task)); private bool CanEdit(TeachingTask task) => - currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || - IsAssignedTeacher(task); + IsTaskOperable(task) && (currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || + IsAssignedTeacher(task)); private bool CanEditBoundSessionRecords( ExperimentGradeSheet sheet, IReadOnlyCollection requestedRecords) { var scope = currentUserDataScope.Current; - if (sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled || + if (!IsTaskOperable(sheet.ExperimentProject!.TeachingTask!) || + sheet.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled || scope.Scope != DataScope.Self || !scope.IsInRole(SystemRoles.Teacher) || requestedRecords.Count == 0) return false; @@ -1320,6 +1331,12 @@ public sealed class ExperimentGradesController( task.Teachers.Any(x => x.Teacher?.UserId == currentUserDataScope.Current.UserId); + private static bool IsTaskOperable(TeachingTask task) => + task.Status == TeachingTaskStatus.Published; + + private ActionResult TaskArchivedProblem() => + ConflictProblem("教学任务已归档,实验成绩仅可查看或导出,不能再操作。"); + private bool CanReviewCollege(Guid collegeId) { var scope = currentUserDataScope.Current; diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index fdd08cb..e3067b3 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -272,6 +272,7 @@ public sealed class ExperimentsController( x.Status, x.PublishedAt, x.ClosedAt, + TeachingTaskStatus = x.TeachingTask!.Status, AcademicTermId = x.TeachingTask!.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, TaskNumber = x.TeachingTask.TaskNumber, @@ -368,6 +369,7 @@ public sealed class ExperimentsController( x.SelectionStartsAt, x.SelectionEndsAt, x.Status, + TeachingTaskStatus = x.TeachingTask!.Status, AcademicTermId = x.TeachingTask!.AcademicTermId, TermName = x.TeachingTask.AcademicTerm!.Name, TaskNumber = x.TeachingTask.TaskNumber, @@ -687,6 +689,8 @@ public sealed class ExperimentsController( .ThenInclude(x => x!.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); + if (!IsTaskOperable(project.TeachingTask!)) + return TaskArchivedProblem(); if (project.Status != ExperimentProjectStatus.Draft) return ConflictProblem("只有草稿实验项目可以修改。"); if (project.TeachingTaskId != request.TeachingTaskId) @@ -729,8 +733,11 @@ public sealed class ExperimentsController( CancellationToken cancellationToken) { var project = await ScopedProjects() + .Include(x => x.TeachingTask) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); + if (!IsTaskOperable(project.TeachingTask!)) + return TaskArchivedProblem(); if (project.Status != ExperimentProjectStatus.Draft) return ConflictProblem("只有草稿实验项目可以删除。"); @@ -754,6 +761,8 @@ public sealed class ExperimentsController( .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); + if (!IsTaskOperable(project.TeachingTask!)) + return TaskArchivedProblem(); if (project.Status != ExperimentProjectStatus.Published) return ConflictProblem("只有已发布实验项目可以修正教学内容。"); if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid(); @@ -786,11 +795,14 @@ public sealed class ExperimentsController( return ValidationProblem("请为每个已选实验项目填写一个名称,每行一个且数量必须一致。"); var projects = await ScopedProjects() + .Include(x => x.TeachingTask) .Where(x => ids.Contains(x.Id)) .ToListAsync(cancellationToken); if (projects.Count != ids.Count) return NotFound(); if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft)) return ConflictProblem("批量修改名称只能包含草稿实验项目。"); + if (projects.Any(x => !IsTaskOperable(x.TeachingTask!))) + return TaskArchivedProblem(); var projectsById = projects.ToDictionary(x => x.Id); for (var index = 0; index < ids.Count; index++) @@ -809,11 +821,14 @@ public sealed class ExperimentsController( var ids = ValidateBulkProjectIds(request.ProjectIds); if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。"); var projects = await ScopedProjects() + .Include(x => x.TeachingTask) .Where(x => ids.Contains(x.Id)) .ToListAsync(cancellationToken); if (projects.Count != ids.Count) return NotFound(); if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft)) return ConflictProblem("批量删除只能包含草稿实验项目。"); + if (projects.Any(x => !IsTaskOperable(x.TeachingTask!))) + return TaskArchivedProblem(); db.ExperimentProjects.RemoveRange(projects); await db.SaveChangesAsync(cancellationToken); @@ -833,6 +848,8 @@ public sealed class ExperimentsController( .ThenInclude(x => x!.Course) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); + if (!IsTaskOperable(project.TeachingTask!)) + return TaskArchivedProblem(); if (project.Status != ExperimentProjectStatus.Draft) return ConflictProblem("只有草稿实验项目可以发布。"); var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized @@ -872,8 +889,14 @@ public sealed class ExperimentsController( { var ids = ValidateBulkProjectIds(request.ProjectIds); if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。"); - if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count) + var projects = await ScopedProjects().AsNoTracking() + .Where(x => ids.Contains(x.Id)) + .Select(x => new { x.Id, x.TeachingTask!.Status }) + .ToListAsync(cancellationToken); + if (projects.Count != ids.Count) return NotFound(); + if (projects.Any(x => x.Status != TeachingTaskStatus.Published)) + return TaskArchivedProblem(); var userId = currentUserDataScope.Current.UserId; var job = new ExamPublishJob { @@ -908,8 +931,11 @@ public sealed class ExperimentsController( CancellationToken cancellationToken) { var project = await ScopedProjects() + .Include(x => x.TeachingTask) .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (project is null) return NotFound(); + if (!IsTaskOperable(project.TeachingTask!)) + return TaskArchivedProblem(); if (project.Status != ExperimentProjectStatus.Published) return ConflictProblem("只有已发布实验项目可以关闭。"); project.Status = ExperimentProjectStatus.Closed; @@ -930,6 +956,8 @@ public sealed class ExperimentsController( .ThenInclude(x => x!.AcademicTerm) .FirstOrDefaultAsync(x => x.Id == projectId, cancellationToken); if (project is null) return NotFound(); + if (!IsTaskOperable(project.TeachingTask!)) + return TaskArchivedProblem(); if (project.Status == ExperimentProjectStatus.Closed) return ConflictProblem("已关闭实验项目不能再增加场次。"); if (project.ArrangementMode == ExperimentArrangementMode.Centralized) @@ -1009,6 +1037,8 @@ public sealed class ExperimentsController( if (projects.Count != projectIds.Count) return ValidationProblem( "部分实验项目不存在或不在当前管理范围内。"); + if (projects.Values.Any(x => !IsTaskOperable(x.TeachingTask!))) + return TaskArchivedProblem(); var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>( request.Items.Count); @@ -1100,6 +1130,8 @@ public sealed class ExperimentsController( .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); if (session is null) return NotFound(); if (!CanManageSession(session)) return Forbid(); + if (!IsTaskOperable(session.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); if (session.Status == ExperimentSessionStatus.Cancelled) return NoContent(); @@ -1253,6 +1285,8 @@ public sealed class ExperimentsController( var session = await LoadManagedSessionAsync(id, cancellationToken); if (session is null) return NotFound(); if (!CanManageSession(session)) return Forbid(); + if (!IsTaskOperable(session.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); if (session.Status != ExperimentSessionStatus.Scheduled || session.ExperimentProject!.Status != ExperimentProjectStatus.Published || session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled) @@ -1327,6 +1361,8 @@ public sealed class ExperimentsController( var session = await LoadManagedSessionAsync(id, cancellationToken); if (session is null) return NotFound(); if (!CanManageSession(session)) return Forbid(); + if (!IsTaskOperable(session.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); if (session.Status != ExperimentSessionStatus.Scheduled || session.ExperimentProject!.Status != ExperimentProjectStatus.Published || session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled) @@ -1429,6 +1465,8 @@ public sealed class ExperimentsController( session.ExperimentProject!.Status != ExperimentProjectStatus.Published) return NotFound(); + if (!IsTaskOperable(session.ExperimentProject.TeachingTask!)) + return TaskArchivedProblem(); if (session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled) return ConflictProblem("集中安排实验无需学生预约。"); @@ -1517,11 +1555,15 @@ public sealed class ExperimentsController( var booking = await db.ExperimentBookings .Include(x => x.ExperimentSession) + .ThenInclude(x => x!.ExperimentProject) + .ThenInclude(x => x!.TeachingTask) .FirstOrDefaultAsync(x => x.Id == id && x.StudentId == student.Id, cancellationToken); if (booking is null) return NotFound(); + if (!IsTaskOperable(booking.ExperimentSession!.ExperimentProject!.TeachingTask!)) + return TaskArchivedProblem(); if (booking.Status == ExperimentBookingStatus.Cancelled) return NoContent(); @@ -1847,6 +1889,12 @@ public sealed class ExperimentsController( item.Teacher?.UserId == scope.UserId); } + private static bool IsTaskOperable(TeachingTask task) => + task.Status == TeachingTaskStatus.Published; + + private ActionResult TaskArchivedProblem() => + ConflictProblem("教学任务已归档,实验项目、场次和预约仅可查看,不能再操作。"); + private Task CurrentStudentAsync( CancellationToken cancellationToken) => db.Students.FirstOrDefaultAsync(x => diff --git a/web/src/views/ExperimentGradesView.vue b/web/src/views/ExperimentGradesView.vue index ac5dbd0..4752015 100644 --- a/web/src/views/ExperimentGradesView.vue +++ b/web/src/views/ExperimentGradesView.vue @@ -692,7 +692,7 @@ onMounted(async () => { diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index 40da898..b46bdd1 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -928,7 +928,7 @@ onMounted(async () => {
{{ formatSessionTime(session) }}{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }} · 指导:{{ session.instructorNames?.join('、') || '待指定' }}
预约名单 - {{ row.status === 'Draft' ? '删除' : '取消' }} + {{ row.status === 'Draft' ? '删除' : '取消' }}
@@ -945,18 +945,18 @@ onMounted(async () => { 无需预约 @@ -1067,11 +1067,11 @@ onMounted(async () => { 余 {{ session.remainingCount }} / {{ session.capacity }} 已预约 - 预约 + 预约 暂无有效实验场次 - 取消预约 + 取消预约 diff --git a/web/src/views/TeacherAttendanceView.vue b/web/src/views/TeacherAttendanceView.vue index 459df86..8cc3a88 100644 --- a/web/src/views/TeacherAttendanceView.vue +++ b/web/src/views/TeacherAttendanceView.vue @@ -779,12 +779,12 @@ onUnmounted(() => { v-for="task in tasks" :key="task.id" type="button" - :class="{ active: selectedTask?.id === task.id }" + :class="{ active: selectedTask?.id === task.id, 'is-archived': task.status === 'Closed' }" @click="selectTask(task)" > {{ task.courseCode }} · {{ task.taskNumber }} {{ task.courseName }} - {{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表 + {{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表{{ task.status === 'Closed' ? ' · 已归档' : '' }} { {{ selectedTask.courseName }} {{ selectedTask.taskNumber }} · {{ selectedTask.studentCount }} 名学生 - 新建考勤表 + 新建考勤表
{ background: #e9f3f5; border-left: 3px solid #176b87; } +.attendance-task-list button.is-archived { opacity: .58; } .attendance-task-list button > span { color: var(--teal); font: 700 10px/1.2 Consolas, monospace;