调课
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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,8 +227,24 @@ public sealed class GradesController(
|
|||||||
x.SubmittedAt,
|
x.SubmittedAt,
|
||||||
x.ReviewedAt,
|
x.ReviewedAt,
|
||||||
x.PublishedAt,
|
x.PublishedAt,
|
||||||
Records = x.Records
|
x.CreatedAt,
|
||||||
|
x.UpdatedAt
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
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)
|
.OrderBy(record => record.Student!.StudentNumber)
|
||||||
|
.Skip((recordPage - 1) * recordPageSize)
|
||||||
|
.Take(recordPageSize)
|
||||||
.Select(record => new
|
.Select(record => new
|
||||||
{
|
{
|
||||||
record.Id,
|
record.Id,
|
||||||
@@ -223,12 +267,8 @@ public sealed class GradesController(
|
|||||||
record.ExamStatus,
|
record.ExamStatus,
|
||||||
record.Notes,
|
record.Notes,
|
||||||
record.UpdatedAt
|
record.UpdatedAt
|
||||||
}),
|
|
||||||
x.CreatedAt,
|
|
||||||
x.UpdatedAt
|
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (sheet is null) return NotFound();
|
|
||||||
|
|
||||||
var task = await db.TeachingTasks.AsNoTracking()
|
var task = await db.TeachingTasks.AsNoTracking()
|
||||||
.Include(x => x.Teachers)
|
.Include(x => x.Teachers)
|
||||||
@@ -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;""",
|
||||||
|
|||||||
+4867
File diff suppressed because it is too large
Load Diff
+88
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-2
@@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="申请类型" required>
|
<el-form-item label="申请类型" required>
|
||||||
<el-segmented v-model="form.type" :options="[
|
<el-segmented v-model="form.type" :options="[
|
||||||
{ label: '调课', value: 'Reschedule' },
|
{ label: '调课', value: 'Reschedule' },
|
||||||
{ label: '停课', value: 'Cancel' },
|
{ label: '停课', value: 'Cancel' },
|
||||||
{ label: '补课', value: 'Makeup' },
|
{ label: '补课', value: 'Makeup' },
|
||||||
{ label: '代课', value: 'Substitute' },
|
{ label: '代课', value: 'Substitute' },
|
||||||
]" />
|
]" @change="changeType" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-alert
|
||||||
|
:title="form.type === 'Reschedule' ? '把原课次移到另一个日期和节次,原时间不再上课。' : form.type === 'Cancel' ? '只停掉选中的这一次课,不影响其他周次。' : form.type === 'Makeup' ? '保留原课次,另外增加一次补课。' : '为教学班增加代课教师。'"
|
||||||
|
type="info" :closable="false"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<template v-if="showDate(form.type)">
|
<section class="adjustment-step">
|
||||||
<el-form-item label="补课日期" required>
|
<header><b>2</b><div><h3>锁定原课次</h3><p>从已发布课表中选择,不再手填周次和星期。</p></div></header>
|
||||||
<el-date-picker v-model="form.targetDate" type="date" value-format="YYYY-MM-DD" placeholder="选择补课日期" />
|
<el-form-item label="教学班" required>
|
||||||
</el-form-item>
|
<el-select v-model="form.teachingTaskId" filterable placeholder="选择本人承担的教学班" @change="loadSessionOptions">
|
||||||
</template>
|
<el-option v-for="t in tasks" :key="t.id" :label="`${t.taskNumber} · ${t.courseName}`" :value="t.id" />
|
||||||
|
|
||||||
<template v-if="showTypeFields(form.type)">
|
|
||||||
<div class="form-grid three">
|
|
||||||
<el-form-item label="星期">
|
|
||||||
<el-select v-model="form.dayOfWeek" placeholder="选择">
|
|
||||||
<el-option v-for="d in 7" :key="d" :label="['','周一','周二','周三','周四','周五','周六','周日'][d]" :value="d" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="起始节次">
|
<el-form-item v-if="sourceRequired" label="原课次" required>
|
||||||
<el-select v-model="form.startPeriod">
|
<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 label="目标起始节次" required>
|
||||||
|
<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)">
|
||||||
|
<section class="adjustment-step">
|
||||||
|
<header><b>3</b><div><h3>选择代课教师</h3><p>代课教师必须为在职教师。</p></div></header>
|
||||||
<el-form-item label="代课教师" required>
|
<el-form-item label="代课教师" required>
|
||||||
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
|
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师" />
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user