Compare commits
10
Commits
53bee58a84
...
v2.4.2
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Set up .NET
|
- name: Set up .NET
|
||||||
uses: https://github.com/actions/setup-dotnet@v4
|
uses: https://github.com/actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: "10.0.302"
|
dotnet-version: "10.0.400"
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: https://github.com/actions/setup-node@v4
|
uses: https://github.com/actions/setup-node@v4
|
||||||
@@ -46,6 +46,11 @@ jobs:
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
- name: Install Chinese PDF font for backend tests
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --yes fonts-noto-cjk
|
||||||
|
|
||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
run: dotnet test Jiaowu.slnx --configuration Release
|
run: dotnet test Jiaowu.slnx --configuration Release
|
||||||
|
|
||||||
|
|||||||
@@ -503,12 +503,32 @@ public sealed class ExperimentsController(
|
|||||||
x.CourseId != first.CourseId))
|
x.CourseId != first.CourseId))
|
||||||
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
|
||||||
|
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
request.ProjectCount is < 1 or > 100)
|
||||||
|
return ValidationProblem("自行安排一次可生成 1 至 100 个实验项目。");
|
||||||
|
|
||||||
var code = request.Code.Trim();
|
var code = request.Code.Trim();
|
||||||
|
var names = NormalizeBatchProjectNames(request.Names);
|
||||||
|
var definitionCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? request.ProjectCount
|
||||||
|
: 1;
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
names is not null && names.Count != definitionCount)
|
||||||
|
return ValidationProblem($"已填写 {names.Count} 个项目名称,请将生成项目数设为相同数量。");
|
||||||
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
taskIds.Count * definitionCount > 1000)
|
||||||
|
return ValidationProblem("本次最多生成 1000 个自行安排实验项目,请缩小教学任务或项目数量。");
|
||||||
|
|
||||||
|
var projectCodes = Enumerable.Range(1, definitionCount)
|
||||||
|
.Select(index => ProjectCode(code, index, definitionCount))
|
||||||
|
.ToList();
|
||||||
|
if (projectCodes.Any(item => item.Length > 40))
|
||||||
|
return ValidationProblem("项目编码前缀过长,无法追加顺序号。");
|
||||||
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
|
||||||
{
|
{
|
||||||
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
|
||||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
.Where(x => x.Code == code)
|
.Where(x => projectCodes.Contains(x.Code))
|
||||||
.Select(x => x.TeachingTask!.TaskNumber)
|
.Select(x => x.TeachingTask!.TaskNumber)
|
||||||
.OrderBy(x => x)
|
.OrderBy(x => x)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -565,7 +585,7 @@ public sealed class ExperimentsController(
|
|||||||
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
|
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
|
||||||
}
|
}
|
||||||
|
|
||||||
var projects = new List<ExperimentProject>(tasks.Count);
|
var projects = new List<ExperimentProject>(tasks.Count * definitionCount);
|
||||||
foreach (var task in tasks)
|
foreach (var task in tasks)
|
||||||
{
|
{
|
||||||
var item = request.ForTeachingTask(task.Id);
|
var item = request.ForTeachingTask(task.Id);
|
||||||
@@ -594,43 +614,57 @@ public sealed class ExperimentsController(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
projects.Add(new ExperimentProject
|
for (var definitionIndex = 1; definitionIndex <= definitionCount; definitionIndex++)
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
projects.Add(new ExperimentProject
|
||||||
ScheduleEntryId = occurrence.Entry?.Id,
|
{
|
||||||
ScheduleWeek = occurrence.Week,
|
TeachingTaskId = task.Id,
|
||||||
Code = code,
|
ScheduleEntryId = occurrence.Entry?.Id,
|
||||||
Name = request.Name.Trim(),
|
ScheduleWeek = occurrence.Week,
|
||||||
ArrangementMode = request.ArrangementMode,
|
Code = ProjectCode(code, definitionIndex, definitionCount),
|
||||||
Description = Normalize(request.Description),
|
Name = request.Name.Trim(),
|
||||||
Requirements = Normalize(request.Requirements),
|
ArrangementMode = request.ArrangementMode,
|
||||||
StartDate = request.StartDate,
|
Description = Normalize(request.Description),
|
||||||
EndDate = request.EndDate,
|
Requirements = Normalize(request.Requirements),
|
||||||
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
StartDate = request.StartDate,
|
||||||
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
EndDate = request.EndDate,
|
||||||
});
|
SelectionStartsAt = NormalizeSelectionTime(request.SelectionStartsAt),
|
||||||
|
SelectionEndsAt = NormalizeSelectionTime(request.SelectionEndsAt)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var names = NormalizeBatchProjectNames(request.Names);
|
|
||||||
if (request.GenerateSequentialNames)
|
if (request.GenerateSequentialNames)
|
||||||
{
|
{
|
||||||
if (names is not null)
|
if (names is not null)
|
||||||
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
|
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
|
||||||
var width = Math.Max(2, projects.Count.ToString().Length);
|
var width = Math.Max(2, (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? definitionCount
|
||||||
|
: projects.Count).ToString().Length);
|
||||||
if (request.Name.Trim().Length + width + 1 > 120)
|
if (request.Name.Trim().Length + width + 1 > 120)
|
||||||
return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
|
return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
|
||||||
for (var index = 0; index < projects.Count; index++)
|
for (var index = 0; index < projects.Count; index++)
|
||||||
projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}";
|
{
|
||||||
|
var number = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? index % definitionCount + 1
|
||||||
|
: index + 1;
|
||||||
|
projects[index].Name = $"{request.Name.Trim()} {number.ToString($"D{width}")}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (names is not null)
|
else if (names is not null)
|
||||||
{
|
{
|
||||||
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
|
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
|
||||||
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。");
|
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。");
|
||||||
if (names.Count != projects.Count)
|
var expectedNameCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? definitionCount
|
||||||
|
: projects.Count;
|
||||||
|
if (names.Count != expectedNameCount)
|
||||||
return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
|
return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
|
||||||
for (var index = 0; index < projects.Count; index++)
|
for (var index = 0; index < projects.Count; index++)
|
||||||
projects[index].Name = names[index];
|
projects[index].Name = names[request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
|
||||||
|
? index % definitionCount
|
||||||
|
: index];
|
||||||
}
|
}
|
||||||
|
|
||||||
db.ExperimentProjects.AddRange(projects);
|
db.ExperimentProjects.AddRange(projects);
|
||||||
@@ -1174,6 +1208,106 @@ public sealed class ExperimentsController(
|
|||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sessions/{id:guid}/eligible-students")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetProxyBookingStudents(
|
||||||
|
Guid id,
|
||||||
|
string? keyword,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var session = await LoadManagedSessionAsync(id, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (!CanManageSession(session)) return Forbid();
|
||||||
|
if (session.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled)
|
||||||
|
return ConflictProblem("集中安排实验无需代选场次。");
|
||||||
|
|
||||||
|
var bookedStudentIds = await db.ExperimentBookings.AsNoTracking()
|
||||||
|
.Where(x => x.ExperimentProjectId == session.ExperimentProjectId &&
|
||||||
|
x.Status == ExperimentBookingStatus.Booked)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
|
||||||
|
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||||
|
db, [session.ExperimentProject.TeachingTaskId], cancellationToken);
|
||||||
|
return Ok(roster
|
||||||
|
.Where(x => !bookedStudentIds.Contains(x.StudentId))
|
||||||
|
.Where(x => normalizedKeyword is null ||
|
||||||
|
x.StudentNumber.Contains(normalizedKeyword) ||
|
||||||
|
x.Name.Contains(normalizedKeyword) ||
|
||||||
|
x.ClassName.Contains(normalizedKeyword))
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.Take(100)
|
||||||
|
.Select(x => new { x.StudentId, x.StudentNumber, x.Name, x.ClassName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sessions/{id:guid}/proxy-bookings")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateProxyBooking(
|
||||||
|
Guid id,
|
||||||
|
ProxyExperimentBookingRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||||
|
async transaction =>
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var session = await LoadManagedSessionAsync(id, cancellationToken);
|
||||||
|
if (session is null) return NotFound();
|
||||||
|
if (!CanManageSession(session)) return Forbid();
|
||||||
|
if (session.Status != ExperimentSessionStatus.Scheduled ||
|
||||||
|
session.ExperimentProject!.Status != ExperimentProjectStatus.Published ||
|
||||||
|
session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled)
|
||||||
|
return ConflictProblem("只能为已发布的自行安排实验场次代选学生。");
|
||||||
|
|
||||||
|
var student = await db.Students.FirstOrDefaultAsync(x =>
|
||||||
|
x.Id == request.StudentId && x.Status == StudentStatus.Active,
|
||||||
|
cancellationToken);
|
||||||
|
if (student is null) return ValidationProblem("学生不存在或不在学。");
|
||||||
|
if (!await TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id)
|
||||||
|
.ContainsAsync(session.ExperimentProject.TeachingTaskId, cancellationToken))
|
||||||
|
return ValidationProblem("该学生不在本实验项目的教学任务名单内。");
|
||||||
|
|
||||||
|
var existing = await db.ExperimentBookings.FirstOrDefaultAsync(x =>
|
||||||
|
x.ExperimentProjectId == session.ExperimentProjectId && x.StudentId == student.Id,
|
||||||
|
cancellationToken);
|
||||||
|
if (existing?.Status == ExperimentBookingStatus.Booked)
|
||||||
|
return existing.ExperimentSessionId == session.Id
|
||||||
|
? NoContent()
|
||||||
|
: ConflictProblem("该学生已预约本实验项目的其他场次。");
|
||||||
|
|
||||||
|
var scheduleProblem = await StudentConflictAsync(student.Id, session, cancellationToken);
|
||||||
|
if (scheduleProblem is not null) return ConflictProblem(scheduleProblem);
|
||||||
|
var reservedCount = await db.ExperimentBookings.CountAsync(x =>
|
||||||
|
x.ExperimentSessionId == session.Id && x.Status == ExperimentBookingStatus.Booked,
|
||||||
|
cancellationToken);
|
||||||
|
if (reservedCount >= session.Capacity)
|
||||||
|
return ConflictProblem("该实验场次名额已满,请选择其他时间。");
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
db.ExperimentBookings.Add(new ExperimentBooking
|
||||||
|
{
|
||||||
|
ExperimentProjectId = session.ExperimentProjectId,
|
||||||
|
ExperimentSessionId = session.Id,
|
||||||
|
StudentId = student.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.ExperimentSessionId = session.Id;
|
||||||
|
existing.Status = ExperimentBookingStatus.Booked;
|
||||||
|
existing.BookedAt = DateTime.UtcNow;
|
||||||
|
existing.CancelledAt = null;
|
||||||
|
}
|
||||||
|
session.ReservedCount = reservedCount + 1;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
},
|
||||||
|
cancellationToken,
|
||||||
|
IsolationLevel.Serializable);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("sessions/{id:guid}/book")]
|
[HttpPost("sessions/{id:guid}/book")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> Book(
|
public async Task<ActionResult> Book(
|
||||||
@@ -1370,9 +1504,7 @@ public sealed class ExperimentsController(
|
|||||||
.FirstOrDefaultAsync(x =>
|
.FirstOrDefaultAsync(x =>
|
||||||
x.Id == request.ClassroomId && x.IsEnabled,
|
x.Id == request.ClassroomId && x.IsEnabled,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (classroom is null) return "实验教室不存在或已停用。";
|
if (classroom is null) return "实验场地不存在或已停用。";
|
||||||
if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
|
|
||||||
return "所选场地未标注实验教学性质。";
|
|
||||||
if (request.Capacity > classroom.Capacity)
|
if (request.Capacity > classroom.Capacity)
|
||||||
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
|
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
|
||||||
if (project.ArrangementMode ==
|
if (project.ArrangementMode ==
|
||||||
@@ -1572,6 +1704,24 @@ public sealed class ExperimentsController(
|
|||||||
taskIds.Contains(x.TeachingTaskId));
|
taskIds.Contains(x.TeachingTaskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<ExperimentSession?> LoadManagedSessionAsync(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExperimentSessions
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.AcademicTerm)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.Include(x => x.Instructors)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
private bool CanManageSession(ExperimentSession session)
|
private bool CanManageSession(ExperimentSession session)
|
||||||
{
|
{
|
||||||
var scope = currentUserDataScope.Current;
|
var scope = currentUserDataScope.Current;
|
||||||
@@ -1737,6 +1887,11 @@ public sealed class ExperimentsController(
|
|||||||
return names.Select(name => name.Trim()).ToList();
|
return names.Select(name => name.Trim()).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ProjectCode(string prefix, int index, int count) =>
|
||||||
|
count == 1
|
||||||
|
? prefix
|
||||||
|
: $"{prefix}-{index.ToString($"D{Math.Max(2, count.ToString().Length)}")}";
|
||||||
|
|
||||||
private ActionResult ConflictProblem(string detail) =>
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
Conflict(new ProblemDetails
|
Conflict(new ProblemDetails
|
||||||
{
|
{
|
||||||
@@ -1776,7 +1931,8 @@ public sealed record ExperimentProjectBatchRequest(
|
|||||||
IReadOnlyList<string>? Names = null,
|
IReadOnlyList<string>? Names = null,
|
||||||
bool GenerateSequentialNames = false,
|
bool GenerateSequentialNames = false,
|
||||||
DateTime? SelectionStartsAt = null,
|
DateTime? SelectionStartsAt = null,
|
||||||
DateTime? SelectionEndsAt = null)
|
DateTime? SelectionEndsAt = null,
|
||||||
|
[Range(1, 100)] int ProjectCount = 1)
|
||||||
{
|
{
|
||||||
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
|
||||||
new(
|
new(
|
||||||
@@ -1809,6 +1965,8 @@ public sealed record ExperimentSessionRequest(
|
|||||||
[MaxLength(500)] string? Notes,
|
[MaxLength(500)] string? Notes,
|
||||||
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
|
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
|
||||||
|
|
||||||
|
public sealed record ProxyExperimentBookingRequest(Guid StudentId);
|
||||||
|
|
||||||
public sealed record ExperimentSessionBatchRequest(
|
public sealed record ExperimentSessionBatchRequest(
|
||||||
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class PdfTypefaceResolver
|
|||||||
{
|
{
|
||||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"),
|
||||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"),
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
"/usr/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"
|
||||||
};
|
};
|
||||||
|
|||||||
Loaded 3 of 14 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user