This commit is contained in:
2026-07-27 08:59:02 +08:00 Unverified
parent 22321abe23
commit 2f5c6af37c
12 changed files with 6281 additions and 212 deletions
@@ -3,6 +3,8 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -58,6 +60,114 @@ public sealed class CourseAdjustmentsController(
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
} }
[HttpGet("tasks/{teachingTaskId:guid}/session-options")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetSessionOptions(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var task = await AccessibleTeachingTasks().AsNoTracking()
.Where(x =>
x.Id == teachingTaskId &&
x.Status == TeachingTaskStatus.Published)
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name,
x.AcademicTermId,
TermStartDate = x.AcademicTerm!.StartDate,
TermEndDate = x.AcademicTerm.EndDate
})
.FirstOrDefaultAsync(cancellationToken);
if (task is null) return NotFound();
var entries = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published)
.OrderBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.Select(x => new
{
x.Id,
x.DayOfWeek,
x.StartPeriod,
x.PeriodCount,
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.ClassroomId,
ClassroomName = x.Classroom != null ? x.Classroom.Name : null,
BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null
})
.ToListAsync(cancellationToken);
var occupied = await db.CourseAdjustments.AsNoTracking()
.Where(x =>
x.TeachingTaskId == teachingTaskId &&
x.SourceScheduleEntryId != null &&
x.SourceWeek != null &&
(x.Status == CourseAdjustmentStatus.Submitted ||
x.Status == CourseAdjustmentStatus.Approved))
.Select(x => new { x.SourceScheduleEntryId, x.SourceWeek })
.ToListAsync(cancellationToken);
var occupiedOccurrences = occupied
.Select(x => (x.SourceScheduleEntryId!.Value, x.SourceWeek!.Value))
.ToHashSet();
var sessions = entries
.SelectMany(entry => Enumerable.Range(
entry.StartWeek,
entry.EndWeek - entry.StartWeek + 1)
.Where(week => IncludesWeek(entry.WeekPattern, week))
.Select(week => new
{
ScheduleEntryId = entry.Id,
Week = week,
Date = ResolveDate(task.TermStartDate, week, entry.DayOfWeek),
entry.DayOfWeek,
entry.StartPeriod,
entry.PeriodCount,
entry.ClassroomId,
entry.ClassroomName,
entry.BuildingName,
HasExistingAdjustment = occupiedOccurrences.Contains((entry.Id, week))
}))
.Where(x => x.Date >= task.TermStartDate && x.Date <= task.TermEndDate)
.OrderBy(x => x.Date)
.ThenBy(x => x.StartPeriod)
.ToList();
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.OrderBy(x => x.Building!.Campus!.SortOrder)
.ThenBy(x => x.Building!.SortOrder)
.ThenBy(x => x.SortOrder)
.Select(x => new
{
x.Id,
x.Name,
BuildingName = x.Building!.Name,
CampusName = x.Building.Campus!.Name,
x.Capacity
})
.ToListAsync(cancellationToken);
var periods = await db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.AcademicTermId == task.AcademicTermId && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new
{
x.PeriodNumber,
x.Name,
StartsAt = x.StartsAt.ToString("HH:mm"),
EndsAt = x.EndsAt.ToString("HH:mm")
})
.ToListAsync(cancellationToken);
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
}
// ═══════════════ My adjustments ═══════════════ // ═══════════════ My adjustments ═══════════════
[HttpGet("mine")] [HttpGet("mine")]
@@ -131,20 +241,41 @@ public sealed class CourseAdjustmentsController(
var validation = await ValidateRequestAsync(request, cancellationToken); var validation = await ValidateRequestAsync(request, cancellationToken);
if (validation is not null) return validation; if (validation is not null) return validation;
var source = RequiresSourceOccurrence(request.Type)
? await LoadSourceOccurrenceAsync(
request.TeachingTaskId,
request.SourceScheduleEntryId,
request.SourceWeek,
cancellationToken)
: null;
var targetDayOfWeek = request.TargetDate.HasValue
? ToIsoDayOfWeek(request.TargetDate.Value.DayOfWeek)
: request.DayOfWeek;
var adj = new CourseAdjustment var adj = new CourseAdjustment
{ {
TeachingTaskId = request.TeachingTaskId, TeachingTaskId = request.TeachingTaskId,
Type = request.Type, Type = request.Type,
ApplicantUserId = userId, ApplicantUserId = userId,
Reason = request.Reason.Trim(), Reason = request.Reason.Trim(),
SourceScheduleEntryId = source?.Entry.Id,
SourceWeek = source?.Week,
SourceDate = source?.Date,
SourceStartPeriod = source?.Entry.StartPeriod,
SourcePeriodCount = source?.Entry.PeriodCount,
SourceClassroomId = source?.Entry.ClassroomId,
TargetDate = request.TargetDate, TargetDate = request.TargetDate,
DayOfWeek = request.DayOfWeek, DayOfWeek = targetDayOfWeek,
StartPeriod = request.StartPeriod, StartPeriod = request.StartPeriod,
PeriodCount = request.PeriodCount, PeriodCount = source?.Entry.PeriodCount ?? request.PeriodCount,
ClassroomId = request.ClassroomId, ClassroomId = request.ClassroomId,
SubstituteTeacherId = request.SubstituteTeacherId, SubstituteTeacherId = request.SubstituteTeacherId,
CancelWeek = request.CancelWeek, CancelWeek = request.Type == CourseAdjustmentType.Cancel
CancelDate = request.CancelDate ? source?.Week
: request.CancelWeek,
CancelDate = request.Type == CourseAdjustmentType.Cancel
? source?.Date
: request.CancelDate
}; };
if (request.Submit) if (request.Submit)
@@ -193,6 +324,25 @@ public sealed class CourseAdjustmentsController(
if (adj.Status != CourseAdjustmentStatus.Draft) if (adj.Status != CourseAdjustmentStatus.Draft)
return ConflictProblem("只有草稿可以提交。"); return ConflictProblem("只有草稿可以提交。");
var validation = await ValidateRequestAsync(
new CourseAdjustmentRequest(
adj.TeachingTaskId,
adj.Type,
adj.Reason,
true,
adj.TargetDate,
adj.DayOfWeek,
adj.StartPeriod,
adj.PeriodCount,
adj.ClassroomId,
adj.SubstituteTeacherId,
adj.CancelWeek,
adj.CancelDate,
adj.SourceScheduleEntryId,
adj.SourceWeek),
cancellationToken);
if (validation is not null) return validation;
adj.Status = CourseAdjustmentStatus.Submitted; adj.Status = CourseAdjustmentStatus.Submitted;
adj.SubmittedAt = DateTime.UtcNow; adj.SubmittedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
@@ -224,6 +374,12 @@ public sealed class CourseAdjustmentsController(
.Include(x => x.TeachingTask) .Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course) .ThenInclude(x => x!.Course)
.ThenInclude(x => x!.College) .ThenInclude(x => x!.College)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (adj is null) return NotFound(); if (adj is null) return NotFound();
@@ -234,6 +390,12 @@ public sealed class CourseAdjustmentsController(
if (adj.Status != CourseAdjustmentStatus.Submitted) if (adj.Status != CourseAdjustmentStatus.Submitted)
return ConflictProblem("只有待审核的申请可以审批。"); return ConflictProblem("只有待审核的申请可以审批。");
var scheduleProblem = await ValidateApprovalScheduleAsync(
adj,
cancellationToken);
if (scheduleProblem is not null)
return ConflictProblem(scheduleProblem);
adj.Status = CourseAdjustmentStatus.Approved; adj.Status = CourseAdjustmentStatus.Approved;
adj.ReviewedAt = DateTime.UtcNow; adj.ReviewedAt = DateTime.UtcNow;
adj.ReviewedByUserId = currentUserDataScope.Current.UserId; adj.ReviewedByUserId = currentUserDataScope.Current.UserId;
@@ -251,11 +413,9 @@ public sealed class CourseAdjustmentsController(
NotificationCategory.Schedule); NotificationCategory.Schedule);
// Notify affected students // Notify affected students
var studentUserIds = await db.CourseEnrollments var studentUserIds = await TeachingTaskRosterQuery
.Where(x => .ForTask(db, adj.TeachingTaskId)
x.CourseSelectionOffering!.TeachingTaskId == adj.TeachingTaskId && .Select(x => x.UserId)
x.Status == CourseEnrollmentStatus.Enrolled)
.Select(x => x.Student!.UserId)
.Where(uid => uid != null) .Where(uid => uid != null)
.Select(uid => uid!.Value) .Select(uid => uid!.Value)
.Distinct() .Distinct()
@@ -278,81 +438,19 @@ public sealed class CourseAdjustmentsController(
switch (adj.Type) switch (adj.Type)
{ {
case CourseAdjustmentType.Reschedule: case CourseAdjustmentType.Reschedule:
// Update existing schedule entries for this teaching task var rescheduleSource = await LoadSourceEntryAsync(adj, ct);
if (adj.DayOfWeek.HasValue && adj.StartPeriod.HasValue) ExcludeWeek(rescheduleSource, adj.SourceWeek!.Value);
{ db.ScheduleEntries.Add(CreateTargetEntry(adj, rescheduleSource.SchedulePlanId));
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId)
.ToListAsync(ct);
foreach (var entry in entries)
{
entry.DayOfWeek = adj.DayOfWeek.Value;
entry.StartPeriod = adj.StartPeriod.Value;
entry.PeriodCount = adj.PeriodCount ?? entry.PeriodCount;
}
if (adj.ClassroomId.HasValue)
{
foreach (var entry in entries)
entry.ClassroomId = adj.ClassroomId.Value;
}
}
break; break;
case CourseAdjustmentType.Cancel: case CourseAdjustmentType.Cancel:
// Cancel: remove schedule entries for the specified week var cancelSource = await LoadSourceEntryAsync(adj, ct);
if (adj.CancelWeek.HasValue) ExcludeWeek(cancelSource, adj.SourceWeek!.Value);
{
var entries = await db.ScheduleEntries
.Where(x => x.TeachingTaskId == adj.TeachingTaskId &&
x.StartWeek <= adj.CancelWeek.Value &&
x.EndWeek >= adj.CancelWeek.Value)
.ToListAsync(ct);
foreach (var entry in entries)
{
// Split the entry to exclude the cancelled week
if (entry.StartWeek == adj.CancelWeek.Value &&
entry.EndWeek == adj.CancelWeek.Value)
{
db.ScheduleEntries.Remove(entry);
}
else if (entry.StartWeek == adj.CancelWeek.Value)
{
entry.StartWeek = adj.CancelWeek.Value + 1;
}
else if (entry.EndWeek == adj.CancelWeek.Value)
{
entry.EndWeek = adj.CancelWeek.Value - 1;
}
}
}
break; break;
case CourseAdjustmentType.Makeup: case CourseAdjustmentType.Makeup:
// Makeup: add a temp schedule entry for the makeup date var makeupSource = await LoadSourceEntryAsync(adj, ct);
if (adj.TargetDate.HasValue && adj.DayOfWeek.HasValue && db.ScheduleEntries.Add(CreateTargetEntry(adj, makeupSource.SchedulePlanId));
adj.StartPeriod.HasValue)
{
var publishedPlan = await db.SchedulePlans
.Where(x => x.AcademicTermId == adj.TeachingTask!.AcademicTermId &&
x.Status == SchedulePlanStatus.Published)
.FirstOrDefaultAsync(ct);
if (publishedPlan is not null)
{
db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = publishedPlan.Id,
TeachingTaskId = adj.TeachingTaskId,
ClassroomId = adj.ClassroomId,
DayOfWeek = adj.DayOfWeek.Value,
StartPeriod = adj.StartPeriod.Value,
PeriodCount = adj.PeriodCount ?? 2,
StartWeek = 1,
EndWeek = 1,
WeekPattern = WeekPattern.All,
Notes = $"补课(原申请 {adj.CreatedAt:yyyy-MM-dd}"
});
}
}
break; break;
case CourseAdjustmentType.Substitute: case CourseAdjustmentType.Substitute:
@@ -442,15 +540,13 @@ public sealed class CourseAdjustmentsController(
{ {
case CourseAdjustmentType.Reschedule: case CourseAdjustmentType.Reschedule:
case CourseAdjustmentType.Makeup: case CourseAdjustmentType.Makeup:
if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7) if (!request.TargetDate.HasValue)
return ValidationProblem("请选择有效的上课日。"); return ValidationProblem(
request.Type == CourseAdjustmentType.Reschedule
? "请选择调课后的日期。"
: "请选择补课日期。");
if (!request.StartPeriod.HasValue || request.StartPeriod < 1) if (!request.StartPeriod.HasValue || request.StartPeriod < 1)
return ValidationProblem("请选择起始节次。"); return ValidationProblem("请选择起始节次。");
if (!request.PeriodCount.HasValue || request.PeriodCount < 1)
return ValidationProblem("请选择持续节数。");
if (request.Type == CourseAdjustmentType.Makeup &&
!request.TargetDate.HasValue)
return ValidationProblem("补课必须指定日期。");
break; break;
case CourseAdjustmentType.Substitute: case CourseAdjustmentType.Substitute:
if (!request.SubstituteTeacherId.HasValue) if (!request.SubstituteTeacherId.HasValue)
@@ -461,14 +557,226 @@ public sealed class CourseAdjustmentsController(
return ValidationProblem("代课教师不存在或已离职。"); return ValidationProblem("代课教师不存在或已离职。");
break; break;
case CourseAdjustmentType.Cancel: case CourseAdjustmentType.Cancel:
if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue)
return ValidationProblem("停课需指定周次或日期。");
break; break;
} }
if (RequiresSourceOccurrence(request.Type))
{
var source = await LoadSourceOccurrenceAsync(
request.TeachingTaskId,
request.SourceScheduleEntryId,
request.SourceWeek,
cancellationToken);
if (source is null)
return ValidationProblem("请选择当前已发布课表中的具体原课次。");
var duplicate = await db.CourseAdjustments.AsNoTracking().AnyAsync(x =>
x.SourceScheduleEntryId == source.Entry.Id &&
x.SourceWeek == source.Week &&
(x.Status == CourseAdjustmentStatus.Submitted ||
x.Status == CourseAdjustmentStatus.Approved),
cancellationToken);
if (duplicate)
return ConflictProblem("该课次已有待审核或已通过的调停课记录。");
if (request.TargetDate.HasValue &&
(request.TargetDate < source.Term.StartDate ||
request.TargetDate > source.Term.EndDate))
return ValidationProblem("目标日期必须在本学期起止日期内。");
}
if (request.ClassroomId.HasValue &&
!await db.Classrooms.AsNoTracking().AnyAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken))
return ValidationProblem("目标教室不存在或已停用。");
return null;
}
private async Task<string?> ValidateApprovalScheduleAsync(
CourseAdjustment adjustment,
CancellationToken cancellationToken)
{
if (!RequiresSourceOccurrence(adjustment.Type)) return null;
var source = await LoadSourceOccurrenceAsync(
adjustment.TeachingTaskId,
adjustment.SourceScheduleEntryId,
adjustment.SourceWeek,
cancellationToken);
if (source is null)
return "原课次已不在当前发布课表中,请退回后重新选择。";
if (adjustment.Type == CourseAdjustmentType.Cancel) return null;
if (!adjustment.TargetDate.HasValue || !adjustment.StartPeriod.HasValue)
return "目标上课日期或节次不完整。";
var targetWeek = ResolveWeek(source.Term.StartDate, adjustment.TargetDate.Value);
var targetDay = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek);
var target = new ScheduleEntry
{
TeachingTaskId = adjustment.TeachingTaskId,
TeachingTask = adjustment.TeachingTask,
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
DayOfWeek = targetDay,
StartPeriod = adjustment.StartPeriod.Value,
PeriodCount = adjustment.SourcePeriodCount ?? source.Entry.PeriodCount,
StartWeek = targetWeek,
EndWeek = targetWeek,
WeekPattern = WeekPattern.All
};
var possibleConflicts = await db.ScheduleEntries.AsNoTracking()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Classes)
.Where(x =>
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.Id != adjustment.SourceScheduleEntryId &&
x.DayOfWeek == targetDay &&
x.StartWeek <= targetWeek &&
x.EndWeek >= targetWeek &&
x.StartPeriod < target.StartPeriod + target.PeriodCount &&
target.StartPeriod < x.StartPeriod + x.PeriodCount)
.ToListAsync(cancellationToken);
foreach (var existing in possibleConflicts.Where(x =>
IncludesWeek(x.WeekPattern, targetWeek)))
{
var reason = ScheduleConflictDetector.ConflictReason(target, existing);
if (reason is not null)
return $"目标时间存在{reason}冲突:{existing.TeachingTask!.Name}。";
}
return null; return null;
} }
private async Task<SourceOccurrence?> LoadSourceOccurrenceAsync(
Guid teachingTaskId,
Guid? scheduleEntryId,
int? week,
CancellationToken cancellationToken)
{
if (!scheduleEntryId.HasValue || !week.HasValue) return null;
var entry = await db.ScheduleEntries.AsNoTracking()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.FirstOrDefaultAsync(x =>
x.Id == scheduleEntryId.Value &&
x.TeachingTaskId == teachingTaskId &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
cancellationToken);
if (entry?.TeachingTask?.AcademicTerm is null ||
week < entry.StartWeek || week > entry.EndWeek ||
!IncludesWeek(entry.WeekPattern, week.Value))
return null;
var date = ResolveDate(
entry.TeachingTask.AcademicTerm.StartDate,
week.Value,
entry.DayOfWeek);
if (date < entry.TeachingTask.AcademicTerm.StartDate ||
date > entry.TeachingTask.AcademicTerm.EndDate)
return null;
return new SourceOccurrence(
entry,
entry.TeachingTask.AcademicTerm,
week.Value,
date);
}
private Task<ScheduleEntry> LoadSourceEntryAsync(
CourseAdjustment adjustment,
CancellationToken cancellationToken) =>
db.ScheduleEntries.SingleAsync(x =>
x.Id == adjustment.SourceScheduleEntryId &&
x.TeachingTaskId == adjustment.TeachingTaskId &&
x.SchedulePlan!.Status == SchedulePlanStatus.Published,
cancellationToken);
private void ExcludeWeek(ScheduleEntry entry, int week)
{
db.ScheduleEntries.Remove(entry);
if (entry.StartWeek <= week - 1 &&
HasIncludedWeek(entry.WeekPattern, entry.StartWeek, week - 1))
db.ScheduleEntries.Add(CloneEntry(entry, entry.StartWeek, week - 1));
if (week + 1 <= entry.EndWeek &&
HasIncludedWeek(entry.WeekPattern, week + 1, entry.EndWeek))
db.ScheduleEntries.Add(CloneEntry(entry, week + 1, entry.EndWeek));
}
private ScheduleEntry CreateTargetEntry(
CourseAdjustment adjustment,
Guid schedulePlanId)
{
var targetWeek = ResolveWeek(
adjustment.TeachingTask!.AcademicTerm!.StartDate,
adjustment.TargetDate!.Value);
return new ScheduleEntry
{
SchedulePlanId = schedulePlanId,
TeachingTaskId = adjustment.TeachingTaskId,
ClassroomId = adjustment.ClassroomId ?? adjustment.SourceClassroomId,
DayOfWeek = ToIsoDayOfWeek(adjustment.TargetDate.Value.DayOfWeek),
StartPeriod = adjustment.StartPeriod!.Value,
PeriodCount = adjustment.SourcePeriodCount!.Value,
StartWeek = targetWeek,
EndWeek = targetWeek,
WeekPattern = WeekPattern.All,
Notes = adjustment.Type == CourseAdjustmentType.Reschedule
? $"调课(原第 {adjustment.SourceWeek} 周)"
: $"补课(对应原第 {adjustment.SourceWeek} 周课次)"
};
}
private static ScheduleEntry CloneEntry(
ScheduleEntry source,
int startWeek,
int endWeek) => new()
{
SchedulePlanId = source.SchedulePlanId,
TeachingTaskId = source.TeachingTaskId,
ClassroomId = source.ClassroomId,
DayOfWeek = source.DayOfWeek,
StartPeriod = source.StartPeriod,
PeriodCount = source.PeriodCount,
StartWeek = startWeek,
EndWeek = endWeek,
WeekPattern = source.WeekPattern,
Notes = source.Notes
};
private static bool RequiresSourceOccurrence(CourseAdjustmentType type) =>
type is CourseAdjustmentType.Reschedule or
CourseAdjustmentType.Cancel or
CourseAdjustmentType.Makeup;
private static bool IncludesWeek(WeekPattern pattern, int week) =>
pattern == WeekPattern.All ||
pattern == WeekPattern.Odd && week % 2 == 1 ||
pattern == WeekPattern.Even && week % 2 == 0;
private static bool HasIncludedWeek(WeekPattern pattern, int startWeek, int endWeek) =>
Enumerable.Range(startWeek, endWeek - startWeek + 1)
.Any(week => IncludesWeek(pattern, week));
private static DateOnly ResolveDate(DateOnly termStartDate, int week, int dayOfWeek)
{
var startDay = (int)termStartDate.DayOfWeek;
var daysSinceMonday = (startDay + 6) % 7;
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
return firstWeekMonday.AddDays((week - 1) * 7 + dayOfWeek - 1);
}
private static int ResolveWeek(DateOnly termStartDate, DateOnly date)
{
var startDay = (int)termStartDate.DayOfWeek;
var daysSinceMonday = (startDay + 6) % 7;
var firstWeekMonday = termStartDate.AddDays(-daysSinceMonday);
return (date.DayNumber - firstWeekMonday.DayNumber) / 7 + 1;
}
private static int ToIsoDayOfWeek(DayOfWeek dayOfWeek) =>
dayOfWeek == System.DayOfWeek.Sunday ? 7 : (int)dayOfWeek;
private IQueryable<TeachingTask> AccessibleTeachingTasks() private IQueryable<TeachingTask> AccessibleTeachingTasks()
{ {
var source = db.TeachingTasks.AsQueryable(); var source = db.TeachingTasks.AsQueryable();
@@ -483,7 +791,7 @@ public sealed class CourseAdjustmentsController(
return source.Where(_ => false); return source.Where(_ => false);
} }
private static System.Linq.Expressions.Expression< private System.Linq.Expressions.Expression<
Func<CourseAdjustment, object>> AdjustmentProjection() => x => new Func<CourseAdjustment, object>> AdjustmentProjection() => x => new
{ {
x.Id, x.Id,
@@ -500,6 +808,17 @@ public sealed class CourseAdjustmentsController(
x.Status, x.Status,
x.Reason, x.Reason,
x.ReviewComment, x.ReviewComment,
x.SourceScheduleEntryId,
x.SourceWeek,
x.SourceDate,
x.SourceStartPeriod,
x.SourcePeriodCount,
SourceClassroomName = x.SourceClassroomId == null
? null
: db.Classrooms
.Where(room => room.Id == x.SourceClassroomId)
.Select(room => room.Building!.Name + " " + room.Name)
.FirstOrDefault(),
x.TargetDate, x.TargetDate,
x.DayOfWeek, x.DayOfWeek,
x.StartPeriod, x.StartPeriod,
@@ -515,6 +834,12 @@ public sealed class CourseAdjustmentsController(
x.CreatedAt x.CreatedAt
}; };
private sealed record SourceOccurrence(
ScheduleEntry Entry,
AcademicTerm Term,
int Week,
DateOnly Date);
private ActionResult ConflictProblem(string detail) => private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails Conflict(new ProblemDetails
{ {
@@ -538,7 +863,9 @@ public sealed record CourseAdjustmentRequest(
Guid? ClassroomId, Guid? ClassroomId,
Guid? SubstituteTeacherId, Guid? SubstituteTeacherId,
int? CancelWeek, int? CancelWeek,
DateOnly? CancelDate); DateOnly? CancelDate,
Guid? SourceScheduleEntryId = null,
int? SourceWeek = null);
public sealed record RejectionRequest( public sealed record RejectionRequest(
[MaxLength(500)] string? Comment); [MaxLength(500)] string? Comment);
+101 -32
View File
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
@@ -41,18 +42,38 @@ public sealed class GradesController(
public async Task<ActionResult> GetSheets( public async Task<ActionResult> GetSheets(
Guid? academicTermId, Guid? academicTermId,
GradeSheetStatus? status, GradeSheetStatus? status,
CancellationToken cancellationToken) string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{ {
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
var source = AccessibleTasks().AsNoTracking() var source = AccessibleTasks().AsNoTracking()
.Where(x => .Where(x =>
x.Status == TeachingTaskStatus.Published || x.Status == TeachingTaskStatus.Published ||
x.Status == TeachingTaskStatus.Closed); x.Status == TeachingTaskStatus.Closed);
if (academicTermId.HasValue) if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId); source = source.Where(x => x.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x => db.GradeSheets.Any(sheet =>
sheet.TeachingTaskId == x.Id && sheet.Status == status.Value));
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.TaskNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.Course!.Code.Contains(keyword) ||
x.Course.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate) .OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber) .ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -96,10 +117,7 @@ public sealed class GradesController(
.FirstOrDefault() .FirstOrDefault()
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
if (status.HasValue)
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
return Ok(items);
} }
[HttpPost("sheets")] [HttpPost("sheets")]
@@ -164,8 +182,18 @@ public sealed class GradesController(
[HttpGet("sheets/{id:guid}")] [HttpGet("sheets/{id:guid}")]
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{ {
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await AccessibleSheets().AsNoTracking() var sheet = await AccessibleSheets().AsNoTracking()
.Where(x => x.Id == id) .Where(x => x.Id == id)
.Select(x => new .Select(x => new
@@ -199,37 +227,49 @@ public sealed class GradesController(
x.SubmittedAt, x.SubmittedAt,
x.ReviewedAt, x.ReviewedAt,
x.PublishedAt, x.PublishedAt,
Records = x.Records
.OrderBy(record => record.Student!.StudentNumber)
.Select(record => new
{
record.Id,
record.StudentId,
record.Student!.StudentNumber,
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
record.Notes,
record.UpdatedAt
}),
x.CreatedAt, x.CreatedAt,
x.UpdatedAt x.UpdatedAt
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound(); if (sheet is null) return NotFound();
var recordsSource = db.GradeRecords.AsNoTracking()
.Where(record => record.GradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(record =>
record.Student!.StudentNumber.Contains(studentKeyword) ||
record.Student.Name.Contains(studentKeyword) ||
record.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(record => record.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(record => new
{
record.Id,
record.StudentId,
record.Student!.StudentNumber,
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore,
record.GradePoint,
record.ExamStatus,
record.Notes,
record.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking() var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers) .Include(x => x.Teachers)
.ThenInclude(x => x.Teacher) .ThenInclude(x => x.Teacher)
@@ -243,7 +283,36 @@ public sealed class GradesController(
return Ok(new return Ok(new
{ {
Sheet = sheet, Sheet = new
{
sheet.Id,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCode,
sheet.CourseName,
sheet.CourseCollegeId,
sheet.CourseCollegeName,
sheet.Credits,
sheet.TeacherNames,
sheet.ClassNames,
sheet.RegularWeight,
sheet.FinalWeight,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEditScores(task) && CanEdit = CanEditScores(task) &&
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned, sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted, CanReview = isCourseCollegeReviewer && sheet.Status == GradeSheetStatus.Submitted,
@@ -10,7 +10,15 @@ public sealed class CourseAdjustment : EntityBase
public CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft; public CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft;
public Guid ApplicantUserId { get; set; } public Guid ApplicantUserId { get; set; }
// For Reschedule / Makeup // The concrete published timetable occurrence being adjusted.
public Guid? SourceScheduleEntryId { get; set; }
public int? SourceWeek { get; set; }
public DateOnly? SourceDate { get; set; }
public int? SourceStartPeriod { get; set; }
public int? SourcePeriodCount { get; set; }
public Guid? SourceClassroomId { get; set; }
// Target occurrence for Reschedule / Makeup
public DateOnly? TargetDate { get; set; } public DateOnly? TargetDate { get; set; }
public int? DayOfWeek { get; set; } public int? DayOfWeek { get; set; }
public int? StartPeriod { get; set; } public int? StartPeriod { get; set; }
@@ -953,6 +953,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => new { x.TeachingTaskId, x.Status }); entity.HasIndex(x => new { x.TeachingTaskId, x.Status });
entity.HasIndex(x => x.ApplicantUserId); entity.HasIndex(x => x.ApplicantUserId);
entity.HasIndex(x => new { x.Status, x.CreatedAt }); entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => new { x.SourceScheduleEntryId, x.SourceWeek });
entity.HasOne(x => x.TeachingTask).WithMany() entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany() entity.HasOne(x => x.Classroom).WithMany()
@@ -60,6 +60,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_32_classroom_reservations"; "20260726_32_classroom_reservations";
private const string BackgroundJobOutboxMigration = private const string BackgroundJobOutboxMigration =
"20260726_33_background_job_outbox"; "20260726_33_background_job_outbox";
private const string CourseAdjustmentOccurrencesMigration =
"20260727_34_course_adjustment_occurrences";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -278,6 +280,21 @@ public sealed class DevelopmentSqliteMigrator(
courseAdjustmentsExist ? [] : CourseAdjustmentsStatements, courseAdjustmentsExist ? [] : CourseAdjustmentsStatements,
cancellationToken); cancellationToken);
var courseAdjustmentOccurrencesExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('CourseAdjustments')
WHERE name = 'SourceScheduleEntryId'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
CourseAdjustmentOccurrencesMigration,
courseAdjustmentOccurrencesExist
? []
: CourseAdjustmentOccurrencesStatements,
cancellationToken);
var attendanceAppealExists = await db.Database var attendanceAppealExists = await db.Database
.SqlQueryRaw<int>( .SqlQueryRaw<int>(
""" """
@@ -1772,6 +1789,12 @@ public sealed class DevelopmentSqliteMigrator(
"Type" INTEGER NOT NULL, "Type" INTEGER NOT NULL,
"Status" INTEGER NOT NULL, "Status" INTEGER NOT NULL,
"ApplicantUserId" TEXT NOT NULL, "ApplicantUserId" TEXT NOT NULL,
"SourceScheduleEntryId" TEXT NULL,
"SourceWeek" INTEGER NULL,
"SourceDate" TEXT NULL,
"SourceStartPeriod" INTEGER NULL,
"SourcePeriodCount" INTEGER NULL,
"SourceClassroomId" TEXT NULL,
"TargetDate" TEXT NULL, "TargetDate" TEXT NULL,
"DayOfWeek" INTEGER NULL, "DayOfWeek" INTEGER NULL,
"StartPeriod" INTEGER NULL, "StartPeriod" INTEGER NULL,
@@ -1817,6 +1840,17 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");""" """CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");"""
]; ];
private static readonly string[] CourseAdjustmentOccurrencesStatements =
[
"""ALTER TABLE "CourseAdjustments" ADD "SourceScheduleEntryId" TEXT NULL;""",
"""ALTER TABLE "CourseAdjustments" ADD "SourceWeek" INTEGER NULL;""",
"""ALTER TABLE "CourseAdjustments" ADD "SourceDate" TEXT NULL;""",
"""ALTER TABLE "CourseAdjustments" ADD "SourceStartPeriod" INTEGER NULL;""",
"""ALTER TABLE "CourseAdjustments" ADD "SourcePeriodCount" INTEGER NULL;""",
"""ALTER TABLE "CourseAdjustments" ADD "SourceClassroomId" TEXT NULL;""",
"""CREATE INDEX "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek" ON "CourseAdjustments" ("SourceScheduleEntryId", "SourceWeek");"""
];
private static readonly string[] AttendanceAppealStatements = private static readonly string[] AttendanceAppealStatements =
[ [
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealStatus" INTEGER NOT NULL DEFAULT 0;""", """ALTER TABLE "AttendanceRecords" ADD COLUMN "AppealStatus" INTEGER NOT NULL DEFAULT 0;""",
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseAdjustmentOccurrences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "SourceClassroomId",
table: "CourseAdjustments",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "SourceDate",
table: "CourseAdjustments",
type: "date",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SourcePeriodCount",
table: "CourseAdjustments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "SourceScheduleEntryId",
table: "CourseAdjustments",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SourceStartPeriod",
table: "CourseAdjustments",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SourceWeek",
table: "CourseAdjustments",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek",
table: "CourseAdjustments",
columns: new[] { "SourceScheduleEntryId", "SourceWeek" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_CourseAdjustments_SourceScheduleEntryId_SourceWeek",
table: "CourseAdjustments");
migrationBuilder.DropColumn(
name: "SourceClassroomId",
table: "CourseAdjustments");
migrationBuilder.DropColumn(
name: "SourceDate",
table: "CourseAdjustments");
migrationBuilder.DropColumn(
name: "SourcePeriodCount",
table: "CourseAdjustments");
migrationBuilder.DropColumn(
name: "SourceScheduleEntryId",
table: "CourseAdjustments");
migrationBuilder.DropColumn(
name: "SourceStartPeriod",
table: "CourseAdjustments");
migrationBuilder.DropColumn(
name: "SourceWeek",
table: "CourseAdjustments");
}
}
}
@@ -714,6 +714,24 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid?>("ReviewedByUserId") b.Property<Guid?>("ReviewedByUserId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
b.Property<Guid?>("SourceClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime?>("SourceDate")
.HasColumnType("date");
b.Property<int?>("SourcePeriodCount")
.HasColumnType("int");
b.Property<Guid?>("SourceScheduleEntryId")
.HasColumnType("char(36)");
b.Property<int?>("SourceStartPeriod")
.HasColumnType("int");
b.Property<int?>("SourceWeek")
.HasColumnType("int");
b.Property<int?>("StartPeriod") b.Property<int?>("StartPeriod")
.HasColumnType("int"); .HasColumnType("int");
@@ -746,6 +764,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("SubstituteTeacherId"); b.HasIndex("SubstituteTeacherId");
b.HasIndex("SourceScheduleEntryId", "SourceWeek");
b.HasIndex("Status", "CreatedAt"); b.HasIndex("Status", "CreatedAt");
b.HasIndex("TeachingTaskId", "Status"); b.HasIndex("TeachingTaskId", "Status");
@@ -3473,10 +3493,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("JobKind", "JobId") b.HasIndex("JobKind", "JobId")
.IsUnique(); .IsUnique();
b.HasIndex("State", "CreatedAt");
b.HasIndex("State", "CompletedAt"); b.HasIndex("State", "CompletedAt");
b.HasIndex("State", "CreatedAt");
b.ToTable("BackgroundJobOutboxMessages"); b.ToTable("BackgroundJobOutboxMessages");
}); });
@@ -0,0 +1,274 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class CourseAdjustmentsControllerTests
{
[Fact]
public async Task Reschedule_MovesOnlyTheSelectedOccurrence()
{
await using var fixture = await Fixture.CreateAsync();
var teacherController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
var targetDate = new DateOnly(2026, 9, 24);
var created = await teacherController.Create(
new CourseAdjustmentRequest(
fixture.Task.Id,
CourseAdjustmentType.Reschedule,
"参加学院教研活动",
true,
targetDate,
null,
3,
null,
fixture.Classroom.Id,
null,
null,
null,
fixture.Entry.Id,
3),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var adjustment = await fixture.Db.CourseAdjustments.SingleAsync();
Assert.Equal(new DateOnly(2026, 9, 16), adjustment.SourceDate);
Assert.Equal(1, adjustment.SourceStartPeriod);
Assert.Equal(2, adjustment.SourcePeriodCount);
var adminController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(Guid.NewGuid(), DataScope.All, SystemRoles.SuperAdmin));
var approved = await adminController.Approve(
adjustment.Id,
CancellationToken.None);
Assert.IsType<NoContentResult>(approved);
var entries = await fixture.Db.ScheduleEntries
.OrderBy(x => x.StartWeek)
.ThenBy(x => x.DayOfWeek)
.ToListAsync();
Assert.Contains(entries, x =>
x.DayOfWeek == 3 && x.StartWeek == 1 && x.EndWeek == 2);
Assert.Contains(entries, x =>
x.DayOfWeek == 3 && x.StartWeek == 4 && x.EndWeek == 5);
Assert.Contains(entries, x =>
x.DayOfWeek == 4 && x.StartWeek == 4 && x.EndWeek == 4 &&
x.StartPeriod == 3 && x.PeriodCount == 2);
Assert.DoesNotContain(entries, x =>
x.DayOfWeek == 3 && x.StartWeek <= 3 && x.EndWeek >= 3);
}
[Fact]
public async Task Cancel_RemovesOnlyTheSelectedMiddleWeek()
{
await using var fixture = await Fixture.CreateAsync();
var teacherController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
await teacherController.Create(
new CourseAdjustmentRequest(
fixture.Task.Id,
CourseAdjustmentType.Cancel,
"参加培训",
true,
null,
null,
null,
null,
null,
null,
null,
null,
fixture.Entry.Id,
3),
CancellationToken.None);
var adjustment = await fixture.Db.CourseAdjustments.SingleAsync();
var adminController = new CourseAdjustmentsController(
fixture.Db,
new TestScope(Guid.NewGuid(), DataScope.All, SystemRoles.SuperAdmin));
var approved = await adminController.Approve(
adjustment.Id,
CancellationToken.None);
Assert.IsType<NoContentResult>(approved);
var entries = await fixture.Db.ScheduleEntries
.OrderBy(x => x.StartWeek)
.ToListAsync();
Assert.Equal(2, entries.Count);
Assert.Contains(entries, x => x.StartWeek == 1 && x.EndWeek == 2);
Assert.Contains(entries, x => x.StartWeek == 4 && x.EndWeek == 5);
}
private sealed class Fixture : IAsyncDisposable
{
private readonly SqliteConnection connection;
private Fixture(
SqliteConnection connection,
AppDbContext db,
Guid teacherUserId,
TeachingTask task,
ScheduleEntry entry,
Classroom classroom)
{
this.connection = connection;
Db = db;
TeacherUserId = teacherUserId;
Task = task;
Entry = entry;
Classroom = classroom;
}
public AppDbContext Db { get; }
public Guid TeacherUserId { get; }
public TeachingTask Task { get; }
public ScheduleEntry Entry { get; }
public Classroom Classroom { get; }
public static async Task<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var teacherUserId = Guid.NewGuid();
var user = new ApplicationUser
{
Id = teacherUserId,
UserName = "T001",
NormalizedUserName = "T001",
DisplayName = "张老师"
};
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var college = new College
{
Code = "CS",
Name = "计算机学院",
CampusId = campus.Id
};
var building = new Building
{
Code = "A",
Name = "教学楼 A",
CampusId = campus.Id
};
var classroom = new Classroom
{
Code = "A101",
Name = "A101",
BuildingId = building.Id,
Capacity = 60
};
var teacher = new Teacher
{
TeacherNumber = "T001",
Name = "张老师",
CollegeId = college.Id,
UserId = teacherUserId
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var task = new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
]
};
var plan = new SchedulePlan
{
AcademicTermId = term.Id,
Name = "正式课表",
Version = "1.0",
Status = SchedulePlanStatus.Published
};
var entry = new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = task.Id,
ClassroomId = classroom.Id,
DayOfWeek = 3,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 5,
WeekPattern = WeekPattern.All
};
db.AddRange(
user,
campus,
college,
building,
classroom,
teacher,
course,
term,
task,
plan,
entry);
await db.SaveChangesAsync();
return new Fixture(connection, db, teacherUserId, task, entry, classroom);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
}
private sealed class TestScope(
Guid userId,
DataScope scope,
string role) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"测试用户",
null,
scope,
new HashSet<string>([role]));
}
}
@@ -0,0 +1,150 @@
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class GradesPaginationTests
{
[Fact]
public async Task GradeTasksAndStudentRecords_AreReturnedByPage()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "080901",
Name = "计算机科学与技术",
CollegeId = college.Id,
DegreeType = "工学学士"
};
var administrativeClass = new AdministrativeClass
{
Code = "CS2026-01",
Name = "计科 2026-1 班",
MajorId = major.Id,
Grade = 2026
};
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 20)
};
var tasks = Enumerable.Range(1, 11)
.Select(number => new TeachingTask
{
TaskNumber = $"2026-1-CS101-{number:00}",
Name = $"程序设计基础教学班 {number}",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Status = TeachingTaskStatus.Published
})
.ToList();
var students = Enumerable.Range(1, 11)
.Select(number => new Student
{
StudentNumber = $"202601{number:000}",
Name = $"学生 {number}",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
})
.ToList();
var sheet = new GradeSheet
{
TeachingTaskId = tasks[0].Id,
RegularWeight = 30,
FinalWeight = 70,
Records = students.Select(student => new GradeRecord
{
StudentId = student.Id
}).ToList()
};
db.AddRange(college, major, administrativeClass, course, term);
db.AddRange(tasks);
db.AddRange(students);
db.Add(sheet);
await db.SaveChangesAsync();
var controller = new GradesController(db, new AllScope());
var listResult = await controller.GetSheets(
term.Id,
null,
null,
1,
10,
CancellationToken.None);
var listOk = Assert.IsType<OkObjectResult>(listResult);
var page = Assert.IsType<PagedResult<object>>(listOk.Value);
Assert.Equal(11, page.Total);
Assert.Equal(10, page.Items.Count);
var firstDetail = Assert.IsType<OkObjectResult>(await controller.GetSheet(
sheet.Id,
1,
10,
null,
CancellationToken.None));
var firstSheet = firstDetail.Value!.GetType().GetProperty("Sheet")!
.GetValue(firstDetail.Value)!;
Assert.Equal(11, ReadInt(firstSheet, "RecordTotal"));
Assert.Equal(10, ReadItems(firstSheet, "Records").Count);
var secondDetail = Assert.IsType<OkObjectResult>(await controller.GetSheet(
sheet.Id,
2,
10,
null,
CancellationToken.None));
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
.GetValue(secondDetail.Value)!;
Assert.Single(ReadItems(secondSheet, "Records"));
}
private static int ReadInt(object value, string property) =>
(int)value.GetType().GetProperty(property)!.GetValue(value)!;
private static List<object> ReadItems(object value, string property) =>
((System.Collections.IEnumerable)value.GetType().GetProperty(property)!
.GetValue(value)!).Cast<object>().ToList();
private sealed class AllScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
}
+206 -65
View File
@@ -20,11 +20,19 @@ const currentAdj = ref<any>(null)
const rejectComment = ref('') const rejectComment = ref('')
const terms = ref<any[]>([]) const terms = ref<any[]>([])
const tasks = ref<any[]>([]) const tasks = ref<any[]>([])
const sessionOptions = ref<any[]>([])
const classrooms = ref<any[]>([])
const periods = ref<any[]>([])
const sessionTask = ref<any>(null)
const sessionLoading = ref(false)
const termId = ref<string>() const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine') const tab = ref(isManager.value ? 'reviews' : 'mine')
const form = reactive({ const form = reactive({
teachingTaskId: undefined as string | undefined, teachingTaskId: undefined as string | undefined,
sourceKey: undefined as string | undefined,
sourceScheduleEntryId: undefined as string | undefined,
sourceWeek: undefined as number | undefined,
type: 'Reschedule' as string, type: 'Reschedule' as string,
reason: '', reason: '',
submit: true, submit: true,
@@ -41,11 +49,36 @@ const form = reactive({
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' } const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' } const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
const statusColors: Record<string, 'info' | 'warning' | 'success' | 'danger'> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' } const statusColors: Record<string, 'info' | 'warning' | 'success' | 'danger'> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
const sourceRequired = computed(() => ['Reschedule', 'Cancel', 'Makeup'].includes(form.type))
const selectedSession = computed(() =>
sessionOptions.value.find(item => sessionKey(item) === form.sourceKey))
function periodOptions() { function periodOptions() {
if (periods.value.length) {
return periods.value.map(item => ({
value: item.periodNumber,
label: `${item.periodNumber} 节 · ${item.name} ${item.startsAt}${item.endsAt}`,
}))
}
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}` })) return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
} }
function sessionKey(session: any) {
return `${session.scheduleEntryId}:${session.week}`
}
function weekdayLabel(day: number) {
return ['','一','二','三','四','五','六','日'][day]
}
function sessionLabel(session: any) {
const end = session.startPeriod + session.periodCount - 1
const room = session.classroomName
? ` · ${session.buildingName ?? ''}${session.classroomName}`
: ' · 无固定教室'
return `${session.week} 周 · ${session.date}${weekdayLabel(session.dayOfWeek)} · 第 ${session.startPeriod}${end}${room}`
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -66,6 +99,9 @@ async function load() {
function openCreate() { function openCreate() {
Object.assign(form, { Object.assign(form, {
teachingTaskId: undefined, teachingTaskId: undefined,
sourceKey: undefined,
sourceScheduleEntryId: undefined,
sourceWeek: undefined,
type: 'Reschedule', type: 'Reschedule',
reason: '', reason: '',
submit: true, submit: true,
@@ -81,12 +117,51 @@ function openCreate() {
dialog.value = true dialog.value = true
} }
async function loadSessionOptions() {
sessionOptions.value = []
classrooms.value = []
periods.value = []
sessionTask.value = null
form.sourceKey = undefined
form.sourceScheduleEntryId = undefined
form.sourceWeek = undefined
if (!form.teachingTaskId) return
sessionLoading.value = true
try {
const data = (await http.get(
`/course-adjustments/tasks/${form.teachingTaskId}/session-options`,
)).data
sessionTask.value = data.task
sessionOptions.value = data.sessions
classrooms.value = data.classrooms
periods.value = data.periods
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
sessionLoading.value = false
}
}
function selectSession() {
const session = selectedSession.value
if (!session) return
form.sourceScheduleEntryId = session.scheduleEntryId
form.sourceWeek = session.week
form.periodCount = session.periodCount
form.classroomId = session.classroomId ?? undefined
}
async function submit() { async function submit() {
if (!form.teachingTaskId) { ElMessage.warning('请选择教学班'); return } if (!form.teachingTaskId) { ElMessage.warning('请选择教学班'); return }
if (sourceRequired.value && !selectedSession.value) { ElMessage.warning('请选择要处理的原课次'); return }
if (selectedSession.value?.hasExistingAdjustment) { ElMessage.warning('该课次已有调停课记录,请选择其他课次'); return }
if (['Reschedule', 'Makeup'].includes(form.type) && !form.targetDate) { ElMessage.warning('请选择目标日期'); return }
if (['Reschedule', 'Makeup'].includes(form.type) && !form.startPeriod) { ElMessage.warning('请选择目标节次'); return }
if (!form.reason.trim()) { ElMessage.warning('请填写申请原因'); return } if (!form.reason.trim()) { ElMessage.warning('请填写申请原因'); return }
try { try {
await http.post('/course-adjustments', { await http.post('/course-adjustments', {
...form, ...form,
sourceKey: undefined,
dayOfWeek: form.dayOfWeek || null, dayOfWeek: form.dayOfWeek || null,
startPeriod: form.startPeriod || null, startPeriod: form.startPeriod || null,
classroomId: form.classroomId || null, classroomId: form.classroomId || null,
@@ -159,12 +234,15 @@ onMounted(async () => {
await load() await load()
}) })
function showTypeFields(type: string) { function changeType() {
form.targetDate = ''
form.startPeriod = undefined
form.classroomId = selectedSession.value?.classroomId ?? undefined
}
function showTarget(type: string) {
return type === 'Reschedule' || type === 'Makeup' return type === 'Reschedule' || type === 'Makeup'
} }
function showDate(type: string) { return type === 'Makeup' }
function showSub(type: string) { return type === 'Substitute' } function showSub(type: string) { return type === 'Substitute' }
function showCancel(type: string) { return type === 'Cancel' }
</script> </script>
<template> <template>
@@ -209,14 +287,18 @@ function showCancel(type: string) { return type === 'Cancel' }
</header> </header>
<div class="adj-body"> <div class="adj-body">
<p v-if="adj.reason"><b>原因</b>{{ adj.reason }}</p> <p v-if="adj.reason"><b>原因</b>{{ adj.reason }}</p>
<div v-if="adj.sourceDate" class="adjustment-route">
<div><small>原课次</small><b> {{ adj.sourceWeek }} · {{ adj.sourceDate }} · {{ adj.sourceStartPeriod }}{{ adj.sourceStartPeriod + adj.sourcePeriodCount - 1 }} </b><span>{{ adj.sourceClassroomName || '无固定教室' }}</span></div>
<i>{{ adj.type === 'Cancel' ? '×' : '→' }}</i>
<div v-if="adj.type !== 'Cancel'"><small>{{ adj.type === 'Makeup' ? '新增补课' : '调整至' }}</small><b>{{ adj.targetDate }} · {{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }} </b><span>{{ adj.classroomName ? `${adj.buildingName} ${adj.classroomName}` : adj.sourceClassroomName || '无固定教室' }}</span></div>
<div v-else><small>处理结果</small><b>仅停掉本次课程</b><span>其他周次保持不变</span></div>
</div>
<div class="adj-details"> <div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span> <span v-if="!adj.sourceDate && adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span> <span v-if="!adj.sourceDate && adj.dayOfWeek"><b>星期</b>{{ weekdayLabel(adj.dayOfWeek) }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span> <span v-if="!adj.sourceDate && adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span> <span v-if="!adj.sourceDate && adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span> <span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div> </div>
<el-alert v-if="adj.reviewComment" :title="`审核意见:${adj.reviewComment}`" type="warning" :closable="false" show-icon /> <el-alert v-if="adj.reviewComment" :title="`审核意见:${adj.reviewComment}`" type="warning" :closable="false" show-icon />
</div> </div>
@@ -242,14 +324,18 @@ function showCancel(type: string) { return type === 'Cancel' }
</header> </header>
<div class="adj-body"> <div class="adj-body">
<p><b>原因</b>{{ adj.reason }}</p> <p><b>原因</b>{{ adj.reason }}</p>
<div v-if="adj.sourceDate" class="adjustment-route">
<div><small>原课次</small><b> {{ adj.sourceWeek }} · {{ adj.sourceDate }} · {{ adj.sourceStartPeriod }}{{ adj.sourceStartPeriod + adj.sourcePeriodCount - 1 }} </b><span>{{ adj.sourceClassroomName || '无固定教室' }}</span></div>
<i>{{ adj.type === 'Cancel' ? '×' : '→' }}</i>
<div v-if="adj.type !== 'Cancel'"><small>{{ adj.type === 'Makeup' ? '新增补课' : '调整至' }}</small><b>{{ adj.targetDate }} · {{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }} </b><span>{{ adj.classroomName ? `${adj.buildingName} ${adj.classroomName}` : adj.sourceClassroomName || '无固定教室' }}</span></div>
<div v-else><small>处理结果</small><b>仅停掉本次课程</b><span>其他周次保持不变</span></div>
</div>
<div class="adj-details"> <div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span> <span v-if="!adj.sourceDate && adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span> <span v-if="!adj.sourceDate && adj.dayOfWeek"><b>星期</b>{{ weekdayLabel(adj.dayOfWeek) }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span> <span v-if="!adj.sourceDate && adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span> <span v-if="!adj.sourceDate && adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span> <span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div> </div>
<p class="adj-meta"><b>提交时间</b>{{ new Date(adj.submittedAt).toLocaleString('zh-CN') }}</p> <p class="adj-meta"><b>提交时间</b>{{ new Date(adj.submittedAt).toLocaleString('zh-CN') }}</p>
</div> </div>
@@ -262,69 +348,91 @@ function showCancel(type: string) { return type === 'Cancel' }
</section> </section>
<!-- Create dialog --> <!-- Create dialog -->
<el-dialog v-model="dialog" title="提交调停课申请" width="680px" top="5vh"> <el-dialog v-model="dialog" title="发起单次课程调整" width="760px" top="4vh" class="adjustment-dialog">
<el-form label-position="top"> <el-form label-position="top" class="adjustment-form">
<el-form-item label="教学班" required> <section class="adjustment-step">
<el-select v-model="form.teachingTaskId" filterable placeholder="选择教学班"> <header><b>1</b><div><h3>选择业务类型</h3><p>每次申请只处理一个具体课次</p></div></header>
<el-option v-for="t in tasks" :key="t.id" :label="`${t.taskNumber} · ${t.courseName}`" :value="t.id" /> <el-form-item label="申请类型" required>
</el-select> <el-segmented v-model="form.type" :options="[
</el-form-item> { label: '调课', value: 'Reschedule' },
<el-form-item label="申请类型" required> { label: '停课', value: 'Cancel' },
<el-segmented v-model="form.type" :options="[ { label: '补课', value: 'Makeup' },
{ label: '课', value: 'Reschedule' }, { label: '课', value: 'Substitute' },
{ label: '停课', value: 'Cancel' }, ]" @change="changeType" />
{ label: '补课', value: 'Makeup' },
{ label: '代课', value: 'Substitute' },
]" />
</el-form-item>
<template v-if="showDate(form.type)">
<el-form-item label="补课日期" required>
<el-date-picker v-model="form.targetDate" type="date" value-format="YYYY-MM-DD" placeholder="选择补课日期" />
</el-form-item> </el-form-item>
</template> <el-alert
:title="form.type === 'Reschedule' ? '把原课次移到另一个日期和节次,原时间不再上课。' : form.type === 'Cancel' ? '只停掉选中的这一次课,不影响其他周次。' : form.type === 'Makeup' ? '保留原课次,另外增加一次补课。' : '为教学班增加代课教师。'"
type="info" :closable="false"
/>
</section>
<template v-if="showTypeFields(form.type)"> <section class="adjustment-step">
<div class="form-grid three"> <header><b>2</b><div><h3>锁定原课次</h3><p>从已发布课表中选择不再手填周次和星期</p></div></header>
<el-form-item label="星期"> <el-form-item label="教学班" required>
<el-select v-model="form.dayOfWeek" placeholder="选择"> <el-select v-model="form.teachingTaskId" filterable placeholder="选择本人承担的教学班" @change="loadSessionOptions">
<el-option v-for="d in 7" :key="d" :label="['','周一','周二','周三','周四','周五','周六','周日'][d]" :value="d" /> <el-option v-for="t in tasks" :key="t.id" :label="`${t.taskNumber} · ${t.courseName}`" :value="t.id" />
</el-select> </el-select>
</el-form-item>
<el-form-item v-if="sourceRequired" label="原课次" required>
<el-select
v-model="form.sourceKey" filterable :loading="sessionLoading"
placeholder="选择第几周、哪一天、哪一节课" @change="selectSession"
>
<el-option
v-for="session in sessionOptions" :key="sessionKey(session)"
:label="sessionLabel(session)" :value="sessionKey(session)"
:disabled="session.hasExistingAdjustment"
>
<span>{{ sessionLabel(session) }}</span>
<small v-if="session.hasExistingAdjustment" class="session-used">已有申请</small>
</el-option>
</el-select>
</el-form-item>
<el-empty v-if="form.teachingTaskId && sourceRequired && !sessionLoading && !sessionOptions.length" description="该教学班没有可用的已发布课次" :image-size="56" />
</section>
<section v-if="showTarget(form.type)" class="adjustment-step target-step">
<header><b>3</b><div><h3>{{ form.type === 'Reschedule' ? '安排调课后课次' : '安排补课课次' }}</h3><p>课程时长沿用原课次系统会在审批时检查教师班级和教室冲突</p></div></header>
<div class="source-target-strip" v-if="selectedSession">
<div><span>原课次</span><strong>{{ sessionLabel(selectedSession) }}</strong></div>
<i></i>
<div><span>目标课次</span><strong>{{ form.targetDate || '待选日期' }} · {{ form.startPeriod ? `${form.startPeriod} 节起` : '待选节次' }}</strong></div>
</div>
<div class="form-grid two">
<el-form-item label="目标日期" required>
<el-date-picker
v-model="form.targetDate" type="date" value-format="YYYY-MM-DD"
:disabled-date="(date: Date) => sessionTask && (date < new Date(`${sessionTask.termStartDate}T00:00:00`) || date > new Date(`${sessionTask.termEndDate}T23:59:59`))"
placeholder="选择本学期内日期"
/>
</el-form-item> </el-form-item>
<el-form-item label="起始节次"> <el-form-item label="目标起始节次" required>
<el-select v-model="form.startPeriod"> <el-select v-model="form.startPeriod" placeholder="选择节次">
<el-option v-for="o in periodOptions()" :key="o.value" :label="o.label" :value="o.value" /> <el-option v-for="o in periodOptions()" :key="o.value" :label="o.label" :value="o.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="持续节数">
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
</el-form-item>
</div> </div>
<el-form-item label="教室(可选)"> <el-form-item label="目标教室">
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室"> <el-select v-model="form.classroomId" clearable filterable placeholder="留空则沿用原教室">
<el-option
v-for="room in classrooms" :key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</template> </section>
<template v-if="showSub(form.type)"> <template v-if="showSub(form.type)">
<el-form-item label="代课教师" required> <section class="adjustment-step">
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师"> <header><b>3</b><div><h3>选择代课教师</h3><p>代课教师必须为在职教师</p></div></header>
</el-select> <el-form-item label="代课教师" required>
</el-form-item> <el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师" />
</el-form-item>
</section>
</template> </template>
<template v-if="showCancel(form.type)"> <el-form-item label="申请原因" required class="reason-field">
<div class="form-grid two">
<el-form-item label="停课周次">
<el-input-number v-model="form.cancelWeek" :min="1" :max="20" placeholder="第几周" />
</el-form-item>
<el-form-item label="停课日期">
<el-date-picker v-model="form.cancelDate" type="date" value-format="YYYY-MM-DD" placeholder="或选择日期" />
</el-form-item>
</div>
</template>
<el-form-item label="申请原因" required>
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请详细说明调停课原因" /> <el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请详细说明调停课原因" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
@@ -370,5 +478,38 @@ function showCancel(type: string) { return type === 'Cancel' }
.adj-card.submitted { border-left: 4px solid #e6a23c; } .adj-card.submitted { border-left: 4px solid #e6a23c; }
.adj-card.approved { border-left: 4px solid #67c23a; } .adj-card.approved { border-left: 4px solid #67c23a; }
.adj-card.rejected { border-left: 4px solid #f56c6c; } .adj-card.rejected { border-left: 4px solid #f56c6c; }
.adjustment-route {
display: grid; grid-template-columns: minmax(0, 1fr) 34px minmax(0, 1fr);
align-items: stretch; gap: 8px; margin: 12px 0; padding: 10px;
background: #f4f7f9; border: 1px solid #dfe7eb;
}
.adjustment-route > div { display: grid; gap: 4px; padding: 9px 11px; background: white; border-left: 3px solid var(--teal); }
.adjustment-route small { color: var(--muted); font-size: 10px; }
.adjustment-route b { font-size: 12px; color: var(--ink); }
.adjustment-route span { color: var(--muted); font-size: 11px; }
.adjustment-route > i { align-self: center; color: var(--indigo); font: 700 20px/1 Consolas, monospace; text-align: center; }
.adjustment-form { display: grid; gap: 14px; }
.adjustment-step { padding: 16px; border: 1px solid var(--line); background: #fbfcfd; }
.adjustment-step > header { display: flex; align-items: flex-start; gap: 11px; margin-bottom: 14px; }
.adjustment-step > header > b {
display: grid; place-items: center; width: 26px; height: 26px; flex: 0 0 26px;
color: white; background: var(--indigo); font: 700 12px/1 Consolas, monospace;
}
.adjustment-step > header h3 { margin: 1px 0 3px; font-size: 14px; }
.adjustment-step > header p { margin: 0; color: var(--muted); font-size: 11px; }
.adjustment-step .el-form-item:last-child { margin-bottom: 0; }
.target-step { border-color: #b9d9d4; background: #f4faf8; }
.source-target-strip { display: grid; grid-template-columns: 1fr 34px 1fr; align-items: stretch; gap: 8px; margin-bottom: 14px; }
.source-target-strip > div { display: grid; gap: 5px; padding: 11px; background: white; border: 1px solid #d9e3e6; }
.source-target-strip span { color: var(--muted); font-size: 10px; }
.source-target-strip strong { font-size: 11px; line-height: 1.55; }
.source-target-strip > i { align-self: center; color: var(--teal); font: 700 20px/1 Consolas, monospace; text-align: center; }
.session-used { float: right; color: #b34e48; }
.reason-field { padding: 14px 16px 0; border-top: 1px solid var(--line); }
@media (max-width: 680px) {
.adjustment-route, .source-target-strip { grid-template-columns: 1fr; }
.adjustment-route > i, .source-target-strip > i { transform: rotate(90deg); padding: 2px; }
.adjustment-step { padding: 13px; }
}
</style> </style>
+113 -23
View File
@@ -9,6 +9,7 @@ import {
Plus, Plus,
Promotion, Promotion,
Refresh, Refresh,
Search,
Upload, Upload,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
@@ -31,6 +32,13 @@ const createDialog = ref(false)
const returnDialog = ref(false) const returnDialog = ref(false)
const termId = ref<string | undefined>() const termId = ref<string | undefined>()
const status = ref<string | undefined>() const status = ref<string | undefined>()
const taskKeyword = ref('')
const taskPage = ref(1)
const taskPageSize = 12
const taskTotal = ref(0)
const recordKeyword = ref('')
const recordPage = ref(1)
const recordPageSize = 50
const createForm = reactive({ const createForm = reactive({
regularWeight: 30, regularWeight: 30,
finalWeight: 70, finalWeight: 70,
@@ -67,8 +75,7 @@ const itemsWeight = computed(() =>
const weightSum = computed(() => const weightSum = computed(() =>
createForm.regularWeight + createForm.finalWeight + itemsWeight.value) createForm.regularWeight + createForm.finalWeight + itemsWeight.value)
const completedCount = computed(() => const completedCount = computed(() =>
detail.value?.records.filter((record: any) => selectedTask.value?.sheet?.completedCount ?? 0)
record.totalScore != null || record.examStatus !== 'Normal').length ?? 0)
const averageScore = computed(() => { const averageScore = computed(() => {
const scores = (detail.value?.records ?? []) const scores = (detail.value?.records ?? [])
.map((record: any) => Number(record.totalScore)) .map((record: any) => Number(record.totalScore))
@@ -78,11 +85,9 @@ const averageScore = computed(() => {
.toFixed(1) .toFixed(1)
}) })
const passRate = computed(() => { const passRate = computed(() => {
const scores = (detail.value?.records ?? []) const completed = Number(selectedTask.value?.sheet?.completedCount ?? 0)
.map((record: any) => Number(record.totalScore)) if (!completed) return '—'
.filter((value: number) => Number.isFinite(value)) return `${Math.round(Number(selectedTask.value?.sheet?.passedCount ?? 0) / completed * 100)}%`
if (!scores.length) return '—'
return `${Math.round(scores.filter((value: number) => value >= 60).length / scores.length * 100)}%`
}) })
const transcriptGpa = computed(() => { const transcriptGpa = computed(() => {
const numeric = transcript.value.records.filter((record: any) => record.gradePoint != null) const numeric = transcript.value.records.filter((record: any) => record.gradePoint != null)
@@ -121,7 +126,7 @@ function removeItem(index: number) {
createForm.items.splice(index, 1) createForm.items.splice(index, 1)
} }
async function load() { async function load(resetPage = false) {
loading.value = true loading.value = true
try { try {
if (isStudent.value) { if (isStudent.value) {
@@ -130,16 +135,23 @@ async function load() {
})).data })).data
return return
} }
tasks.value = (await http.get('/grades/sheets', { if (resetPage) taskPage.value = 1
const response = (await http.get('/grades/sheets', {
params: { params: {
academicTermId: termId.value, academicTermId: termId.value,
status: status.value, status: status.value,
keyword: taskKeyword.value.trim() || undefined,
page: taskPage.value,
pageSize: taskPageSize,
}, },
})).data })).data
tasks.value = response.items
taskTotal.value = response.total
const preferred = tasks.value.find((item) => item.id === selectedTask.value?.id) const preferred = tasks.value.find((item) => item.id === selectedTask.value?.id)
?? tasks.value[0] if (preferred) {
if (preferred) await selectTask(preferred) selectedTask.value = preferred
else { if (preferred.sheet) await loadDetail()
} else {
selectedTask.value = null selectedTask.value = null
detail.value = null detail.value = null
} }
@@ -152,11 +164,24 @@ async function load() {
async function selectTask(task: any) { async function selectTask(task: any) {
selectedTask.value = task selectedTask.value = task
recordPage.value = 1
recordKeyword.value = ''
detail.value = null detail.value = null
if (!task.sheet) return if (!task.sheet) return
await loadDetail()
}
async function loadDetail() {
if (!selectedTask.value?.sheet) return
detailLoading.value = true detailLoading.value = true
try { try {
const response = (await http.get(`/grades/sheets/${task.sheet.id}`)).data const response = (await http.get(`/grades/sheets/${selectedTask.value.sheet.id}`, {
params: {
recordPage: recordPage.value,
recordPageSize,
studentKeyword: recordKeyword.value.trim() || undefined,
},
})).data
detail.value = response.sheet detail.value = response.sheet
detail.value.canEdit = response.canEdit detail.value.canEdit = response.canEdit
detail.value.canReview = response.canReview detail.value.canReview = response.canReview
@@ -168,6 +193,16 @@ async function selectTask(task: any) {
} }
} }
async function changeTaskPage(page: number) {
taskPage.value = page
await load()
}
async function searchRecords() {
recordPage.value = 1
await loadDetail()
}
function openCreate() { function openCreate() {
createForm.regularWeight = 30 createForm.regularWeight = 30
createForm.finalWeight = 70 createForm.finalWeight = 70
@@ -213,7 +248,7 @@ async function saveRecords() {
})), })),
}) })
ElMessage.success('成绩已保存并重新计算总评') ElMessage.success('成绩已保存并重新计算总评')
await selectTask(selectedTask.value) await loadDetail()
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} }
@@ -257,7 +292,7 @@ async function handleImport(event: Event) {
file, file,
) )
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条成绩记录`) ElMessage.success(`导入完成:已更新 ${result.data.updated} 条成绩记录`)
await selectTask(selectedTask.value) await loadDetail()
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -369,7 +404,7 @@ onMounted(async () => {
<p v-if="isStudent">查看学校已正式发布的课程成绩学分与绩点</p> <p v-if="isStudent">查看学校已正式发布的课程成绩学分与绩点</p>
<p v-else>从教师登记学院复核到校级发布支持自定义平时实验实习等分项</p> <p v-else>从教师登记学院复核到校级发布支持自定义平时实验实习等分项</p>
</div> </div>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="load()">刷新</el-button>
</section> </section>
<template v-if="isStudent"> <template v-if="isStudent">
@@ -381,7 +416,7 @@ onMounted(async () => {
</div> </div>
<label> <label>
<span>查看学期</span> <span>查看学期</span>
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-select v-model="termId" clearable placeholder="全部学期" @change="load()">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
</label> </label>
@@ -421,13 +456,22 @@ onMounted(async () => {
<template v-else> <template v-else>
<section class="grade-toolbar"> <section class="grade-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load"> <el-input
v-model="taskKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索课程、课程号或教学班号"
@keyup.enter="load(true)"
@clear="load(true)"
/>
<el-select v-model="termId" clearable placeholder="全部学期" @change="load(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" /> <el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select> </el-select>
<el-select v-model="status" clearable placeholder="全部状态" @change="load"> <el-select v-model="status" clearable placeholder="全部状态" @change="load(true)">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select> </el-select>
<span> {{ tasks.length }} 个可管理教学班</span> <el-button :icon="Search" @click="load(true)">查询</el-button>
<span> {{ taskTotal }} 个可管理教学班</span>
</section> </section>
<section class="grade-workspace" v-loading="loading"> <section class="grade-workspace" v-loading="loading">
@@ -447,10 +491,20 @@ onMounted(async () => {
</i> </i>
</button> </button>
<el-empty v-if="!tasks.length" description="没有可管理的教学班" /> <el-empty v-if="!tasks.length" description="没有可管理的教学班" />
<el-pagination
v-if="taskTotal > taskPageSize"
class="grade-task-pagination"
small
layout="prev, pager, next"
:current-page="taskPage"
:page-size="taskPageSize"
:total="taskTotal"
@current-change="changeTaskPage"
/>
</aside> </aside>
<main class="grade-register" v-loading="detailLoading"> <main class="grade-register" v-loading="detailLoading">
<el-empty v-if="!selectedTask" description="请选择教学班" /> <el-empty v-if="!selectedTask" description="从左侧选择一个教学班,进入对应成绩册" />
<section v-else-if="!selectedTask.sheet" class="grade-create-empty"> <section v-else-if="!selectedTask.sheet" class="grade-create-empty">
<el-icon><DocumentChecked /></el-icon> <el-icon><DocumentChecked /></el-icon>
<h3>{{ selectedTask.courseName }}</h3> <h3>{{ selectedTask.courseName }}</h3>
@@ -474,9 +528,9 @@ onMounted(async () => {
</header> </header>
<section class="grade-register-ruler"> <section class="grade-register-ruler">
<div><span>学生数</span><b>{{ detail.records.length }}</b></div> <div><span>学生数</span><b>{{ detail.recordTotal }}</b></div>
<div><span>已完成</span><b>{{ completedCount }}</b></div> <div><span>已完成</span><b>{{ completedCount }}</b></div>
<div><span>平均</span><b>{{ averageScore }}</b></div> <div><span>本页平均</span><b>{{ averageScore }}</b></div>
<div><span>及格率</span><b>{{ passRate }}</b></div> <div><span>及格率</span><b>{{ passRate }}</b></div>
<p> <p>
平时 {{ detail.regularWeight }}% 平时 {{ detail.regularWeight }}%
@@ -493,6 +547,19 @@ onMounted(async () => {
show-icon show-icon
/> />
<section class="grade-record-toolbar">
<el-input
v-model="recordKeyword"
clearable
:prefix-icon="Search"
placeholder="搜索姓名、学号或行政班"
@keyup.enter="searchRecords"
@clear="searchRecords"
/>
<el-button :icon="Search" @click="searchRecords">查找学生</el-button>
<span>每页最多显示 {{ recordPageSize }} 当前第 {{ detail.recordPage }} </span>
</section>
<div class="grade-table-scroll"> <div class="grade-table-scroll">
<el-table :data="detail.records" class="data-table grade-entry-table"> <el-table :data="detail.records" class="data-table grade-entry-table">
<el-table-column label="学生" fixed min-width="165"> <el-table-column label="学生" fixed min-width="165">
@@ -565,6 +632,16 @@ onMounted(async () => {
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
<el-pagination
v-if="detail.recordTotal > recordPageSize"
class="grade-record-pagination"
background
layout="total, prev, pager, next"
:current-page="recordPage"
:page-size="recordPageSize"
:total="detail.recordTotal"
@current-change="(page: number) => { recordPage = page; loadDetail() }"
/>
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" /> <input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
@@ -688,4 +765,17 @@ onMounted(async () => {
.workflow-status .college-hint { color: #6b4e16; font-weight: 600; } .workflow-status .college-hint { color: #6b4e16; font-weight: 600; }
.grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; } .grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; }
.grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; } .grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.grade-toolbar > .el-input { width: min(320px, 100%); }
.grade-task-pagination { justify-content: center; padding: 14px 8px; border-top: 1px solid var(--line); }
.grade-record-toolbar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 12px 16px; border-bottom: 1px solid var(--line); background: #f7f9fb;
}
.grade-record-toolbar .el-input { width: min(300px, 100%); }
.grade-record-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
.grade-record-pagination { justify-content: flex-end; padding: 14px 16px; border-top: 1px solid var(--line); }
@media (max-width: 760px) {
.grade-record-toolbar > span { width: 100%; margin-left: 0; }
.grade-record-pagination { justify-content: center; }
}
</style> </style>