78 changed files with 59820 additions and 540 deletions
+1
View File
@@ -18,6 +18,7 @@ BackgroundJobs__AutomaticScheduleConcurrency=1
BackgroundJobs__SchedulePublishConcurrency=1
BackgroundJobs__MakeupExamAutoConcurrency=1
BackgroundJobs__ExamArrangementConcurrency=1
BackgroundJobs__CourseGradeStatisticsRefreshConcurrency=1
# RabbitMq__HostName=rabbitmq.example.edu.cn
# RabbitMq__Port=5671
# RabbitMq__UserName=jiaowu
Submodule Academic-Affairs-System.wiki added at c7be1438bc
@@ -251,13 +251,6 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
// Auto-apply: update grade record
gm.GradeRecord!.TotalScore = gm.RequestedScore;
gm.GradeRecord.GradePoint = GradeCalculator.CalculateGradePoint(gm.RequestedScore);
var statisticsJob = new CourseGradeStatisticsRefreshJob
{
GradeSheetId = gm.GradeRecord.GradeSheetId
};
db.CourseGradeStatisticsRefreshJobs.Add(statisticsJob);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh, statisticsJob.Id));
await db.SaveChangesAsync(ct);
await NotificationService.SendAsync(
db,
@@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
@@ -286,6 +287,8 @@ public sealed class CourseAdjustmentsController(
db.CourseAdjustments.Add(adj);
await db.SaveChangesAsync(cancellationToken);
await new PublishedTimetableProjectionService(db)
.RebuildPublishedPlansForTaskAsync(adj.TeachingTaskId, cancellationToken);
if (request.Submit)
{
@@ -0,0 +1,161 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/course-groups")]
public sealed class CourseGroupsController(AppDbContext db) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string ManageRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet]
public async Task<ActionResult> GetAll(CancellationToken cancellationToken)
{
var groups = await db.CourseGroups.AsNoTracking()
.OrderBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Description,
CourseCount = x.Courses.Count,
Courses = x.Courses.OrderBy(item => item.Course!.Code).Select(item => new
{
item.Id,
item.CourseId,
CourseCode = item.Course!.Code,
CourseName = item.Course.Name,
item.Course.Credits,
item.Course.TotalHours,
item.Course.Nature
})
})
.ToListAsync(cancellationToken);
return Ok(groups);
}
[HttpPost]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Create(
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = new CourseGroup
{
Code = request.Code.Trim(),
Name = request.Name.Trim(),
Description = Normalize(request.Description)
};
db.CourseGroups.Add(group);
return await SaveCreatedAsync(group.Id, cancellationToken);
}
[HttpPut("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Update(
Guid id,
CourseGroupRequest request,
CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
group.Code = request.Code.Trim();
group.Name = request.Name.Trim();
group.Description = Normalize(request.Description);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{id:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var group = await db.CourseGroups.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (group is null) return NotFound();
db.CourseGroups.Remove(group);
return await SaveNoContentAsync(cancellationToken);
}
[HttpPost("{id:guid}/courses")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> AddCourse(
Guid id,
CourseGroupCourseRequest request,
CancellationToken cancellationToken)
{
if (!await db.CourseGroups.AnyAsync(x => x.Id == id, cancellationToken)) return NotFound();
if (!await db.Courses.AnyAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
db.CourseGroupCourses.Add(new CourseGroupCourse { CourseGroupId = id, CourseId = request.CourseId });
return await SaveCreatedAsync(id, cancellationToken);
}
[HttpDelete("{id:guid}/courses/{courseId:guid}")]
[Authorize(Roles = ManageRoles)]
public async Task<ActionResult> RemoveCourse(
Guid id,
Guid courseId,
CancellationToken cancellationToken)
{
var item = await db.CourseGroupCourses.FirstOrDefaultAsync(
x => x.CourseGroupId == id && x.CourseId == courseId,
cancellationToken);
if (item is null) return NotFound();
db.CourseGroupCourses.Remove(item);
return await SaveNoContentAsync(cancellationToken);
}
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("课程组编码或组内课程重复。");
}
}
private ActionResult ConflictProblem(string detail) => Conflict(new ProblemDetails
{
Title = "无法完成操作", Detail = detail, Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CourseGroupRequest(
[Required, MaxLength(30)] string Code,
[Required, MaxLength(100)] string Name,
[MaxLength(500)] string? Description);
public sealed record CourseGroupCourseRequest(Guid CourseId);
@@ -1011,7 +1011,11 @@ public sealed class CourseSelectionsController(
x.TeachingTask!.Status == TeachingTaskStatus.Published &&
(x.IsOpenToAll ||
x.TeachingTask.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)))
item.AdministrativeClassId == student.AdministrativeClassId) ||
x.Enrollments.Any(item =>
item.StudentId == student.Id &&
(item.Status == CourseEnrollmentStatus.Enrolled ||
item.Status == CourseEnrollmentStatus.Waitlisted))))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new StudentOfferingDto(
x.Id,
@@ -432,6 +432,48 @@ public sealed class CurriculumPlansController(
return await SaveCreatedAsync(item.Id, cancellationToken);
}
[HttpPost("{planId:guid}/modules/{moduleId:guid}/course-groups/{groupId:guid}")]
public async Task<ActionResult> AddCourseGroup(
Guid planId,
Guid moduleId,
Guid groupId,
CurriculumCourseGroupImportRequest request,
CancellationToken cancellationToken)
{
var plan = await ModifiablePlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
return ValidationProblem("建议学期超出了该专业学制。");
if (!await db.CurriculumModules.AnyAsync(
x => x.Id == moduleId && x.CurriculumPlanId == planId,
cancellationToken))
return NotFound();
var courseIds = await db.CourseGroupCourses.AsNoTracking()
.Where(x => x.CourseGroupId == groupId && x.Course!.IsEnabled)
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (courseIds.Count == 0)
return ValidationProblem("课程组不存在,或其中没有可用课程。");
var existingCourseIds = await db.CurriculumCourses.AsNoTracking()
.Where(x => x.CurriculumModule!.CurriculumPlanId == planId &&
courseIds.Contains(x.CourseId))
.Select(x => x.CourseId)
.ToListAsync(cancellationToken);
if (existingCourseIds.Count > 0)
return ConflictProblem("课程组中有课程已存在于该培养方案,请先移除重复课程后再导入。");
db.CurriculumCourses.AddRange(courseIds.Select(courseId => new CurriculumCourse
{
CurriculumModuleId = moduleId,
CourseId = courseId,
RecommendedSemester = request.RecommendedSemester,
Type = request.Type,
Notes = Normalize(request.Notes)
}));
return await SaveNoContentAsync(cancellationToken);
}
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
public async Task<ActionResult> UpdateCourse(
Guid planId,
@@ -586,3 +628,8 @@ public sealed record CurriculumCourseRequest(
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
public sealed record CurriculumCourseGroupImportRequest(
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
@@ -135,9 +135,179 @@ public sealed class DashboardController(
currentTerm,
counts,
pending,
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
DateTime.UtcNow));
}
[HttpGet("greeting")]
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
CancellationToken cancellationToken)
{
var currentTermId = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
return Ok(await BuildGreetingAsync(
currentUserDataScope.Current,
currentTermId,
null,
null,
cancellationToken));
}
private async Task<DashboardGreeting> BuildGreetingAsync(
CurrentUserScope scope,
Guid? currentTermId,
DashboardCounts? counts,
DashboardPending? pending,
CancellationToken cancellationToken)
{
var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName}";
var greeting = GetTimeGreeting();
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Leader) ||
scope.IsInRole(SystemRoles.Counselor);
if (!isManager && scope.IsInRole(SystemRoles.Student))
{
var studentId = await db.Students.AsNoTracking()
.Where(x => x.UserId == scope.UserId)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
var student = await db.Students.AsNoTracking()
.Where(x => x.Id == studentId.Value)
.Select(x => new { x.AdministrativeClassId })
.FirstAsync(cancellationToken);
var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
currentTermId.HasValue &&
task.AcademicTermId == currentTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
(task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == studentId.Value &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
var taskWorkload = await currentTasks.Select(task => new
{
task.Id,
task.CourseId,
Credits = task.Course!.Credits,
IsClassAssigned = task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)
}).ToListAsync(cancellationToken);
var currentCourses = taskWorkload
.GroupBy(x => x.CourseId)
.Select(x => x.First())
.ToList();
var courseCount = currentCourses.Count;
var courseCredits = currentCourses.Sum(x => x.Credits);
var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
x.StudentId == studentId.Value &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
currentTermId.HasValue &&
x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
var gradeCount = await publishedGrades.CountAsync(cancellationToken);
var average = await publishedGrades
.Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
.AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
var failed = await publishedGrades.CountAsync(x =>
x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
cancellationToken);
var subtitle = failed > 0
? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
: courseCount > 0
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
var narrative = courseCount == 0
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
: failed > 0
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
: gradeCount > 0
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
[
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
]);
}
if (!isManager && scope.IsInRole(SystemRoles.Teacher))
{
var teacherId = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!teacherId.HasValue)
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
var teachingClasses = await tasks.CountAsync(cancellationToken);
var estimatedHours = await tasks.SumAsync(
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
var pendingGrades = await gradeSheets.CountAsync(x =>
x.Status == GradeSheetStatus.Draft ||
x.Status == GradeSheetStatus.Returned, cancellationToken);
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
var subtitle = pendingGrades > 0
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
: teachingClasses > 0
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
: "本学期暂未分配教学班,请留意教学任务安排。";
var narrative = pendingGrades > 0
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
[
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
]);
}
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
pending.ClassroomReservations + pending.GeneralApprovals;
var taskCount = counts?.TeachingTasks ?? 0;
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
var subtitleForManager = actionable > 0
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
: taskCount > 0
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
: "当前学期运行数据已就绪,可从教学任务开始推进。";
var managerNarrative = actionable > 0
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
[
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
]);
}
private static string GetTimeGreeting()
{
var hour = DateTime.UtcNow.AddHours(8).Hour;
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
}
private async Task<DashboardPending> LoadPendingAsync(
CurrentUserScope scope,
Guid? restrictedCollegeId,
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
DashboardTerm? CurrentTerm,
DashboardCounts Counts,
DashboardPending Pending,
DashboardGreeting Greeting,
DateTime GeneratedAt);
public sealed record DashboardGreeting(
string Role,
string Title,
string Subtitle,
string Label,
string Narrative,
IReadOnlyList<DashboardGreetingInsight> Insights);
public sealed record DashboardGreetingInsight(
string Label,
string Value,
string Hint,
string Tone);
public sealed record DashboardAudience(
string Level,
string Title,
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -38,8 +39,15 @@ public sealed class ExperimentGradesController(
public async Task<ActionResult> GetManagement(
Guid? academicTermId,
ExperimentGradeSheetStatus? status,
CancellationToken cancellationToken)
Guid? collegeId = null,
string? keyword = null,
int page = 1,
int pageSize = 20,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 50);
keyword = Normalize(keyword);
var source = ScopedProjects().AsNoTracking()
.Where(x =>
x.Status == ExperimentProjectStatus.Published ||
@@ -51,11 +59,28 @@ public sealed class ExperimentGradesController(
source = source.Where(x =>
x.GradeSheet != null &&
x.GradeSheet.Status == status.Value);
if (collegeId.HasValue)
source = source.Where(x =>
x.TeachingTask!.Course!.CollegeId == collegeId.Value);
if (keyword is not null)
source = source.Where(x =>
x.Code.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.TeachingTask!.TaskNumber.Contains(keyword) ||
x.TeachingTask.Name.Contains(keyword) ||
x.TeachingTask.Course!.Code.Contains(keyword) ||
x.TeachingTask.Course.Name.Contains(keyword) ||
x.TeachingTask.Teachers.Any(item =>
item.Teacher!.TeacherNumber.Contains(keyword) ||
item.Teacher.Name.Contains(keyword)));
return Ok(await source
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.Code)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
@@ -96,7 +121,14 @@ public sealed class ExperimentGradesController(
x.GradeSheet.PublishedAt
}
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize
});
}
[HttpGet("mine")]
@@ -107,7 +139,7 @@ public sealed class ExperimentGradesController(
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
var results = await db.ExperimentGradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status ==
@@ -126,12 +158,19 @@ public sealed class ExperimentGradesController(
ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
TeachingTaskId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTaskId,
AcademicTermId = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.AcademicTermId,
TaskNumber = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.TaskNumber,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.ContributionWeight,
x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus,
x.TotalScore,
@@ -156,7 +195,40 @@ public sealed class ExperimentGradesController(
}),
x.ExperimentGradeSheet.PublishedAt
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
var taskIds = results.Select(x => x.TeachingTaskId).Distinct().ToArray();
var aggregates = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
taskIds.Contains(x.TeachingTaskId))
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var courses = results
.GroupBy(x => x.TeachingTaskId)
.Select(group =>
{
var first = group.First();
aggregates.TryGetValue(group.Key, out var aggregate);
return new
{
TeachingTaskId = group.Key,
first.AcademicTermId,
first.TermName,
first.TaskNumber,
first.CourseCode,
first.CourseName,
ExperimentCourseScore = aggregate?.WeightedAverageScore,
PublishedProjectCount =
aggregate?.PublishedProjectCount ?? group.Count(),
TotalWeight = aggregate?.TotalWeight ??
group.Sum(x => x.ContributionWeight),
RefreshedAt = aggregate?.RefreshedAt,
Projects = group.OrderBy(x => x.ProjectCode).ToList()
};
})
.OrderByDescending(x => x.Projects.Max(project => project.PublishedAt))
.ThenBy(x => x.CourseCode)
.ToList();
return Ok(courses);
}
[HttpPost("sheets")]
@@ -713,7 +785,15 @@ public sealed class ExperimentGradesController(
sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await db.ExecuteInRetriableTransactionAsync(async transaction =>
{
await db.SaveChangesAsync(cancellationToken);
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
db,
sheet.ExperimentProject!.TeachingTaskId,
cancellationToken);
await transaction.CommitAsync(cancellationToken);
}, cancellationToken, IsolationLevel.Serializable);
var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
@@ -747,6 +827,10 @@ public sealed class ExperimentGradesController(
.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)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Experiments;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -31,12 +33,21 @@ public sealed class ExperimentsController(
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetOptions(
Guid? academicTermId,
Guid? offeringCollegeId,
string? courseKeyword,
CancellationToken cancellationToken)
{
var tasks = AccessibleTeachingTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
if (offeringCollegeId.HasValue)
tasks = tasks.Where(x => x.Course!.CollegeId == offeringCollegeId);
var normalizedCourseKeyword = Normalize(courseKeyword);
if (normalizedCourseKeyword is not null)
tasks = tasks.Where(x =>
x.Course!.Code.Contains(normalizedCourseKeyword) ||
x.Course.Name.Contains(normalizedCourseKeyword));
var periods = db.ScheduleTimeSlots.AsNoTracking()
.Where(x => x.IsEnabled);
@@ -90,6 +101,13 @@ public sealed class ExperimentsController(
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name)
})
.Take(200)
.ToListAsync(cancellationToken),
Colleges = await AccessibleTeachingTasks().AsNoTracking()
.Where(x => !academicTermId.HasValue || x.AcademicTermId == academicTermId.Value)
.Select(x => new { x.Course!.CollegeId, CollegeName = x.Course.College!.Name })
.Distinct()
.OrderBy(x => x.CollegeName)
.ToListAsync(cancellationToken),
ScheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
@@ -99,6 +117,11 @@ public sealed class ExperimentsController(
.Contains(x.TeachingTaskId))
.Where(x => !academicTermId.HasValue ||
x.SchedulePlan!.AcademicTermId == academicTermId.Value)
.Where(x => !offeringCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId == offeringCollegeId.Value)
.Where(x => normalizedCourseKeyword == null ||
x.TeachingTask!.Course!.Code.Contains(normalizedCourseKeyword) ||
x.TeachingTask.Course.Name.Contains(normalizedCourseKeyword))
.OrderBy(x => x.TeachingTask!.Course!.Code)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.DayOfWeek)
@@ -116,10 +139,14 @@ public sealed class ExperimentsController(
x.StartWeek,
x.EndWeek,
x.WeekPattern,
ClassNames = x.TeachingTask!.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => item.AdministrativeClass!.Name),
ClassroomName = x.Classroom!.Name,
BuildingName = x.Classroom.Building!.Name,
CampusName = x.Classroom.Building.Campus!.Name
})
.Take(500)
.ToListAsync(cancellationToken),
Classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
@@ -146,7 +173,14 @@ public sealed class ExperimentsController(
Guid? academicTermId,
ExperimentArrangementMode? arrangementMode,
ExperimentProjectStatus? status,
CancellationToken cancellationToken)
Guid? offeringCollegeId,
string? courseKeyword,
string? classKeyword,
string? teacherKeyword,
string? taskKeyword,
CancellationToken cancellationToken,
int page = 1,
int pageSize = 20)
{
var source = ScopedProjects().AsNoTracking();
if (academicTermId.HasValue)
@@ -158,7 +192,47 @@ public sealed class ExperimentsController(
if (status.HasValue)
source = source.Where(x => x.Status == status);
return Ok(await source
var normalizedCourseKeyword = Normalize(courseKeyword);
var normalizedClassKeyword = Normalize(classKeyword);
var normalizedTeacherKeyword = Normalize(teacherKeyword);
var normalizedTaskKeyword = Normalize(taskKeyword);
var taskSource = AccessibleTeachingTasks().AsNoTracking()
.Where(task => source.Select(project => project.TeachingTaskId).Contains(task.Id));
if (academicTermId.HasValue)
taskSource = taskSource.Where(x => x.AcademicTermId == academicTermId);
if (offeringCollegeId.HasValue)
taskSource = taskSource.Where(x => x.Course!.CollegeId == offeringCollegeId);
if (normalizedCourseKeyword is not null)
taskSource = taskSource.Where(x =>
x.Course!.Code.Contains(normalizedCourseKeyword) ||
x.Course.Name.Contains(normalizedCourseKeyword));
if (normalizedClassKeyword is not null)
taskSource = taskSource.Where(x => x.Classes.Any(item =>
item.AdministrativeClass!.Code.Contains(normalizedClassKeyword) ||
item.AdministrativeClass.Name.Contains(normalizedClassKeyword)));
if (normalizedTeacherKeyword is not null)
taskSource = taskSource.Where(x => x.Teachers.Any(item =>
item.Teacher!.TeacherNumber.Contains(normalizedTeacherKeyword) ||
item.Teacher.Name.Contains(normalizedTeacherKeyword)));
if (normalizedTaskKeyword is not null)
taskSource = taskSource.Where(x =>
x.TaskNumber.Contains(normalizedTaskKeyword) ||
x.Name.Contains(normalizedTaskKeyword));
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var total = await taskSource.CountAsync(cancellationToken);
var taskIds = await taskSource
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var items = await source
.Where(x => taskIds.Contains(x.TeachingTaskId))
.OrderByDescending(x => x.Status == ExperimentProjectStatus.Published)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
@@ -167,6 +241,7 @@ public sealed class ExperimentsController(
x.Id,
x.TeachingTaskId,
x.ScheduleEntryId,
x.ScheduleWeek,
x.Code,
x.Name,
x.ArrangementMode,
@@ -197,6 +272,7 @@ public sealed class ExperimentsController(
x.ScheduleEntry.StartWeek,
x.ScheduleEntry.EndWeek,
x.ScheduleEntry.WeekPattern,
ProjectWeek = x.ScheduleWeek,
ClassroomName = x.ScheduleEntry.Classroom!.Name,
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
@@ -220,7 +296,9 @@ public sealed class ExperimentsController(
CampusName = item.Classroom.Building.Campus!.Name
})
})
.ToListAsync(cancellationToken));
.ToListAsync(cancellationToken);
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
}
[HttpGet("student")]
@@ -254,6 +332,7 @@ public sealed class ExperimentsController(
x.Name,
x.ArrangementMode,
x.ScheduleEntryId,
x.ScheduleWeek,
x.Description,
x.Requirements,
x.StartDate,
@@ -275,6 +354,7 @@ public sealed class ExperimentsController(
x.ScheduleEntry.StartWeek,
x.ScheduleEntry.EndWeek,
x.ScheduleEntry.WeekPattern,
ProjectWeek = x.ScheduleWeek,
ClassroomName = x.ScheduleEntry.Classroom!.Name,
BuildingName = x.ScheduleEntry.Classroom.Building!.Name,
CampusName = x.ScheduleEntry.Classroom.Building.Campus!.Name
@@ -363,8 +443,6 @@ public sealed class ExperimentsController(
ExperimentProjectBatchRequest request,
CancellationToken cancellationToken)
{
if (request.ArrangementMode == ExperimentArrangementMode.Centralized)
return ValidationProblem("集中安排的实验项目请逐项绑定已发布课表中的实验课。");
var taskIds = request.TeachingTaskIds
.Where(x => x != Guid.Empty)
.Distinct()
@@ -384,21 +462,72 @@ public sealed class ExperimentsController(
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
var first = tasks[0];
if (tasks.Any(x =>
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled && tasks.Any(x =>
x.AcademicTermId != first.AcademicTermId ||
x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
var code = request.Code.Trim();
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
{
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
}
var scheduledEntries = request.ArrangementMode == ExperimentArrangementMode.Centralized
? await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
x.Kind == ScheduleEntryKind.Experiment &&
x.ClassroomId.HasValue &&
taskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.TeachingTaskId)
.ThenBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.ToListAsync(cancellationToken)
: [];
if (request.ArrangementMode == ExperimentArrangementMode.Centralized &&
tasks.Any(task => scheduledEntries.All(entry => entry.TeachingTaskId != task.Id)))
return ValidationProblem("所选教学班中包含未排入实验室的已发布实验课,请先完成课表安排。");
var scheduledOccurrences = scheduledEntries
.SelectMany(entry => Enumerable.Range(
entry.StartWeek,
entry.EndWeek - entry.StartWeek + 1)
.Where(week => FreeClassroomRules.MatchesWeek(entry.WeekPattern, week))
.Select(week => (Entry: entry, Week: week)))
.ToList();
var legacyProjects = scheduledEntries.Count > 0
? await db.ExperimentProjects
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
x.ScheduleWeek == null &&
taskIds.Contains(x.TeachingTaskId))
.ToListAsync(cancellationToken)
: [];
if (legacyProjects.Any(x => x.Status != ExperimentProjectStatus.Draft))
return ConflictProblem("存在旧版已发布实验项目,不能自动拆分为每周项目;请先关闭后重新设置。");
if (scheduledOccurrences.Count > 0)
{
var entryIds = scheduledOccurrences.Select(x => x.Entry.Id).Distinct().ToList();
var existingOccurrences = await db.ExperimentProjects.AsNoTracking()
.Where(x => x.Code == code && x.ScheduleEntryId.HasValue &&
x.ScheduleWeek.HasValue && entryIds.Contains(x.ScheduleEntryId.Value))
.Select(x => new { ScheduleEntryId = x.ScheduleEntryId!.Value, ScheduleWeek = x.ScheduleWeek!.Value })
.ToListAsync(cancellationToken);
var existingKeys = existingOccurrences
.Select(x => (x.ScheduleEntryId, x.ScheduleWeek))
.ToHashSet();
if (scheduledOccurrences.Any(x => existingKeys.Contains((x.Entry.Id, x.Week))))
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
}
var projects = new List<ExperimentProject>(tasks.Count);
foreach (var task in tasks)
@@ -407,17 +536,63 @@ public sealed class ExperimentsController(
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
projects.Add(new ExperimentProject
IEnumerable<(ScheduleEntry? Entry, int? Week)> taskOccurrences =
request.ArrangementMode == ExperimentArrangementMode.Centralized
? scheduledOccurrences
.Where(item => item.Entry.TeachingTaskId == task.Id)
.Select(item => ((ScheduleEntry?)item.Entry, (int?)item.Week))
: [(null, null)];
foreach (var occurrence in taskOccurrences)
{
TeachingTaskId = task.Id,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
});
var scheduleEntry = occurrence.Entry;
var legacy = scheduleEntry is null ? null : legacyProjects
.SingleOrDefault(x => x.ScheduleEntryId == scheduleEntry.Id);
if (legacy is not null)
{
var firstWeek = scheduledOccurrences
.Where(item => item.Entry.Id == scheduleEntry!.Id)
.Min(item => item.Week);
if (occurrence.Week == firstWeek)
{
legacy.ScheduleWeek = occurrence.Week;
continue;
}
}
projects.Add(new ExperimentProject
{
TeachingTaskId = task.Id,
ScheduleEntryId = occurrence.Entry?.Id,
ScheduleWeek = occurrence.Week,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
});
}
}
var names = NormalizeBatchProjectNames(request.Names);
if (request.GenerateSequentialNames)
{
if (names is not null)
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
var width = Math.Max(2, projects.Count.ToString().Length);
if (request.Name.Trim().Length + width + 1 > 120)
return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
for (var index = 0; index < projects.Count; index++)
projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}";
}
else if (names is not null)
{
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。");
if (names.Count != projects.Count)
return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
for (var index = 0; index < projects.Count; index++)
projects[index].Name = names[index];
}
db.ExperimentProjects.AddRange(projects);
@@ -491,6 +666,54 @@ public sealed class ExperimentsController(
return NoContent();
}
[HttpPut("batch/names")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProjectNames(
ExperimentProjectBatchNamesRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
var names = NormalizeBatchProjectNames(request.Names);
if (names is null || names.Count != ids.Count || names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
return ValidationProblem("请为每个已选实验项目填写一个名称,每行一个且数量必须一致。");
var projects = await ScopedProjects()
.Where(x => ids.Contains(x.Id))
.ToListAsync(cancellationToken);
if (projects.Count != ids.Count) return NotFound();
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
return ConflictProblem("批量修改名称只能包含草稿实验项目。");
var projectsById = projects.ToDictionary(x => x.Id);
for (var index = 0; index < ids.Count; index++)
projectsById[ids[index]].Name = names[index];
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> DeleteProjects(
ExperimentProjectBulkRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
var projects = await ScopedProjects()
.Where(x => ids.Contains(x.Id))
.ToListAsync(cancellationToken);
if (projects.Count != ids.Count) return NotFound();
if (projects.Any(x => x.Status != ExperimentProjectStatus.Draft))
return ConflictProblem("批量删除只能包含草稿实验项目。");
db.ExperimentProjects.RemoveRange(projects);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:guid}/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PublishProject(
@@ -522,31 +745,47 @@ public sealed class ExperimentsController(
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var userIds = await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.Where(x => x.UserId.HasValue)
.Select(x => x.UserId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
if (userIds.Count > 0)
{
var mode = project.ArrangementMode ==
ExperimentArrangementMode.Centralized
? "集中安排"
: "自行预约";
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
await NotifyProjectPublishedAsync(project, cancellationToken);
return NoContent();
}
[HttpPost("batch/publish")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> PublishProjects(
ExperimentProjectBulkRequest request,
CancellationToken cancellationToken)
{
var ids = ValidateBulkProjectIds(request.ProjectIds);
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
return NotFound();
var userId = currentUserDataScope.Current.UserId;
var job = new ExamPublishJob
{
Kind = ExamPublishJobKind.ExperimentProjects,
PlanId = ids[0],
RequestedByUserId = userId == Guid.Empty ? null : userId,
ProjectIdsJson = JsonSerializer.Serialize(ids),
CurrentStep = "等待后台校验"
};
db.ExamPublishJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.ExamPublish, job.Id));
await db.SaveChangesAsync(cancellationToken);
return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
}
[HttpGet("batch/publish-jobs/{jobId:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetPublishJob(Guid jobId, CancellationToken cancellationToken)
{
var job = await db.ExamPublishJobs.AsNoTracking().FirstOrDefaultAsync(x =>
x.Id == jobId && x.Kind == ExamPublishJobKind.ExperimentProjects,
cancellationToken);
if (job is null) return NotFound();
return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt });
}
[HttpPost("{id:guid}/close")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CloseProject(
@@ -1234,6 +1473,31 @@ public sealed class ExperimentsController(
.Distinct()
.ToListAsync(cancellationToken);
private async Task NotifyProjectPublishedAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
if (userIds.Count == 0) return;
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized
? "集中安排"
: "自行预约";
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
{
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
return ids is { Count: > 0 and <= 100 } ? ids : null;
}
private static string? ValidateProjectRequest(
ExperimentProjectRequest request,
AcademicTerm term)
@@ -1279,6 +1543,12 @@ public sealed class ExperimentsController(
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static List<string>? NormalizeBatchProjectNames(IReadOnlyList<string>? names)
{
if (names is null || names.Count == 0) return null;
return names.Select(name => name.Trim()).ToList();
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
@@ -1307,7 +1577,9 @@ public sealed record ExperimentProjectBatchRequest(
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements,
DateOnly StartDate,
DateOnly EndDate)
DateOnly EndDate,
IReadOnlyList<string>? Names = null,
bool GenerateSequentialNames = false)
{
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new(
@@ -1321,6 +1593,13 @@ public sealed record ExperimentProjectBatchRequest(
EndDate);
}
public sealed record ExperimentProjectBulkRequest(
[Required] IReadOnlyList<Guid> ProjectIds);
public sealed record ExperimentProjectBatchNamesRequest(
[Required] IReadOnlyList<Guid> ProjectIds,
[Required] IReadOnlyList<string> Names);
public sealed record ExperimentSessionRequest(
Guid ClassroomId,
DateOnly SessionDate,
@@ -25,6 +25,69 @@ public sealed class GradeAnalyticsController(
SystemRoles.CollegeAdmin + "," +
SystemRoles.Leader + "," +
SystemRoles.Teacher;
private const string ScheduleManagers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
[HttpGet("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> GetRefreshSchedule(CancellationToken cancellationToken)
{
var setting = await db.CourseGradeStatisticsRefreshSettings.AsNoTracking()
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
var defaults = new CourseGradeStatisticsRefreshSetting();
var enabled = setting?.IsEnabled ?? defaults.IsEnabled;
var intervalSeconds = setting?.IntervalSeconds ?? defaults.IntervalSeconds;
var batchSize = setting?.BatchSize ?? defaults.BatchSize;
var lastRunAt = setting?.LastRunAt;
return Ok(new
{
IsEnabled = enabled,
IntervalSeconds = intervalSeconds,
BatchSize = batchSize,
LastRunAt = lastRunAt,
NextRunAt = enabled && lastRunAt.HasValue
? lastRunAt.Value.AddSeconds(intervalSeconds)
: null as DateTime?
});
}
[HttpPut("refresh-schedule")]
[Authorize(Roles = ScheduleManagers)]
public async Task<ActionResult> SaveRefreshSchedule(
SaveGradeStatisticsRefreshScheduleRequest request,
CancellationToken cancellationToken)
{
if (request.IntervalSeconds is < 10 or > 86400)
return BadRequest(new ProblemDetails
{
Title = "刷新间隔应在 10 秒到 24 小时之间。",
Status = StatusCodes.Status400BadRequest
});
if (request.BatchSize is < 1 or > 5000)
return BadRequest(new ProblemDetails
{
Title = "单次刷新批量应在 1 到 5000 之间。",
Status = StatusCodes.Status400BadRequest
});
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
setting.IsEnabled = request.IsEnabled;
setting.IntervalSeconds = request.IntervalSeconds;
setting.BatchSize = request.BatchSize;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("teaching-classes")]
public async Task<ActionResult> GetTeachingClasses(
@@ -526,3 +589,8 @@ public sealed class GradeAnalyticsController(
IReadOnlyList<HistoricalComparison> History,
ComparisonDelta? UniversityDelta);
}
public sealed record SaveGradeStatisticsRefreshScheduleRequest(
bool IsEnabled,
int IntervalSeconds,
int BatchSize);
+1 -12
View File
@@ -151,6 +151,7 @@ public sealed class GradesController(
var task = await AccessibleTasks()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
@@ -508,7 +509,6 @@ public sealed class GradesController(
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
@@ -668,7 +668,6 @@ public sealed class GradesController(
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
QueueCourseStatisticsRefresh(sheet.Id);
// The grade sheet roster is authoritative at publication time. This also
// covers students added through approved roster corrections.
@@ -918,7 +917,6 @@ public sealed class GradesController(
if (errors.Count > 0)
return ImportValidationProblem(errors);
QueueCourseStatisticsRefresh(sheet.Id);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { updated, total = rows.Count });
}
@@ -1161,7 +1159,6 @@ public sealed class GradesController(
{
try
{
QueueCourseStatisticsRefresh(id);
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
@@ -1171,14 +1168,6 @@ public sealed class GradesController(
}
}
private void QueueCourseStatisticsRefresh(Guid gradeSheetId)
{
var job = new CourseGradeStatisticsRefreshJob { GradeSheetId = gradeSheetId };
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh, job.Id));
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
@@ -737,6 +737,7 @@ public sealed class MakeupExamsController(
.Include(x => x.Enrollments)
.Include(x => x.TeachingTask!)
.ThenInclude(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (session is null) return NotFound();
if (session.MakeupExamPlan!.Status != MakeupExamPlanStatus.Published)
@@ -105,6 +105,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
return Ok(tasks.Select(task =>
{
@@ -132,11 +133,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
: constraint?.RequiresClassroom ?? true,
constraint?.RequiredCampusId,
constraint?.RequiredBuildingId,
constraint?.ExperimentRequiredCampusId,
constraint?.ExperimentRequiredBuildingId,
AllowedDayOfWeeks = ParseDays(constraint?.AllowedDayOfWeeks),
constraint?.EarliestPeriod,
constraint?.LatestPeriod,
AllowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId) ?? [],
AllowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId) ?? [],
AllowedExperimentVenueNatures = constraint?.AllowedExperimentVenueNatures ?? 0
};
}));
@@ -168,6 +173,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
{
var flexibleConstraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (flexibleConstraint is not null)
{
@@ -195,6 +201,22 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
cancellationToken))
return ValidationProblem("指定校区不存在或已停用。");
Building? experimentBuilding = null;
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null) return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
var allowedRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(request.AllowedClassroomIds, x => x.Id)
@@ -208,8 +230,23 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
allowedRooms.Any(x => x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。");
var allowedExperimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
var allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(allowedExperimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != allowedExperimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null && allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
var constraint = await db.TeachingTaskScheduleConstraints
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(x => x.TeachingTaskId == teachingTaskId, cancellationToken);
if (constraint is null)
{
@@ -223,6 +260,12 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredBuildingId = request.RequiresClassroom
? request.RequiredBuildingId
: null;
constraint.ExperimentRequiredCampusId = request.RequiresClassroom
? request.ExperimentRequiredCampusId
: null;
constraint.ExperimentRequiredBuildingId = request.RequiresClassroom
? request.ExperimentRequiredBuildingId
: null;
constraint.AllowedDayOfWeeks = request.AllowedDayOfWeeks.Count == 0
? null
: string.Join(',', request.AllowedDayOfWeeks.Distinct().Order());
@@ -230,10 +273,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.LatestPeriod = request.LatestPeriod;
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = request.RequiresClassroom
? request.AllowedClassroomIds.Distinct().Select(classroomId =>
new TeachingTaskAllowedClassroom { ClassroomId = classroomId }).ToList()
: [];
constraint.AllowedExperimentClassrooms = request.RequiresClassroom
? allowedExperimentRoomIds.Select(classroomId =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = classroomId }).ToList()
: [];
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
@@ -256,21 +304,26 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
!Enum.IsDefined(request.SchedulingMode.Value))
return ValidationProblem("授课方式无效。");
if (!request.SchedulingMode.HasValue &&
!request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope &&
!request.UpdatePeriodRange &&
!request.RequiresClassroom.HasValue &&
request.AllowedDayOfWeeks is null &&
!request.UpdateClassroomScope &&
!request.UpdateExperimentClassroomScope &&
!request.AllowedExperimentVenueNatures.HasValue &&
!request.UpdatePeriodRange &&
!request.EarliestPeriod.HasValue &&
!request.LatestPeriod.HasValue)
return ValidationProblem("请至少选择一项需要批量修改的设置。");
if (request.UpdateClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定教室范围时,场地要求不能设置为不占用教室。");
if (request.UpdateExperimentClassroomScope && request.RequiresClassroom == false)
return ValidationProblem("批量指定实验场地时,场地要求不能设置为不占用教室。");
var tasks = await db.TeachingTasks
.Where(x =>
x.AcademicTermId == request.AcademicTermId &&
x.Status == TeachingTaskStatus.Published)
.WhereIn(taskIds, x => x.Id)
.Include(x => x.Course)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Length)
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
@@ -281,9 +334,16 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
(request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定教室,请先将当前筛选结果限定为正常排课课程。");
if (request.UpdateExperimentClassroomScope && tasks.Any(task =>
(request.SchedulingMode ?? task.SchedulingMode) ==
TeachingTaskSchedulingMode.Flexible))
return ConflictProblem("非排时课程不能指定实验场地,请先将当前筛选结果限定为正常排课课程。");
Building? building = null;
List<Classroom> allowedRooms = [];
var experimentRoomIds = request.AllowedExperimentClassroomIds?.Distinct().ToArray() ?? [];
Building? experimentBuilding = null;
List<Classroom> allowedExperimentRooms = [];
if (request.UpdateClassroomScope)
{
if (request.RequiredBuildingId.HasValue)
@@ -320,10 +380,42 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
x.Building!.CampusId != request.RequiredCampusId))
return ValidationProblem("指定教室必须位于所选校区。");
}
if (request.UpdateExperimentClassroomScope)
{
if (request.ExperimentRequiredBuildingId.HasValue)
{
experimentBuilding = await db.Buildings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.ExperimentRequiredBuildingId && x.IsEnabled,
cancellationToken);
if (experimentBuilding is null)
return ValidationProblem("指定实验教学楼不存在或已停用。");
if (request.ExperimentRequiredCampusId.HasValue &&
experimentBuilding.CampusId != request.ExperimentRequiredCampusId)
return ValidationProblem("指定实验教学楼不属于所选实验校区。");
}
if (request.ExperimentRequiredCampusId.HasValue &&
!await db.Campuses.AnyAsync(x => x.Id == request.ExperimentRequiredCampusId && x.IsEnabled,
cancellationToken))
return ValidationProblem("指定实验校区不存在或已停用。");
allowedExperimentRooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
.WhereIn(experimentRoomIds, x => x.Id)
.Include(x => x.Building)
.ToListAsync(cancellationToken);
if (allowedExperimentRooms.Count != experimentRoomIds.Length)
return ValidationProblem("部分指定实验场地不存在或已停用。");
if (experimentBuilding is not null &&
allowedExperimentRooms.Any(x => x.BuildingId != experimentBuilding.Id))
return ValidationProblem("指定实验场地必须位于所选实验教学楼。");
if (request.ExperimentRequiredCampusId.HasValue &&
allowedExperimentRooms.Any(x => x.Building!.CampusId != request.ExperimentRequiredCampusId))
return ValidationProblem("指定实验场地必须位于所选实验校区。");
}
var constraints = await db.TeachingTaskScheduleConstraints
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var task in tasks)
{
@@ -342,6 +434,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
request.RequiresClassroom.HasValue ||
request.AllowedDayOfWeeks is not null ||
request.UpdateClassroomScope ||
request.UpdateExperimentClassroomScope ||
request.AllowedExperimentVenueNatures.HasValue ||
request.UpdatePeriodRange;
if (!changesConstraint) continue;
constraint = new TeachingTaskScheduleConstraint { TeachingTaskId = task.Id };
@@ -357,7 +451,10 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
}
}
if (request.AllowedDayOfWeeks is not null)
@@ -379,6 +476,17 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
ClassroomId = room.Id
}).ToList();
}
if (task.Course?.PracticeHours > 0 && request.AllowedExperimentVenueNatures.HasValue)
constraint.AllowedExperimentVenueNatures = request.AllowedExperimentVenueNatures.Value;
if (task.Course?.PracticeHours > 0 && request.UpdateExperimentClassroomScope)
{
constraint.ExperimentRequiredCampusId = request.ExperimentRequiredCampusId;
constraint.ExperimentRequiredBuildingId = request.ExperimentRequiredBuildingId;
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(
constraint.AllowedExperimentClassrooms);
constraint.AllowedExperimentClassrooms = allowedExperimentRooms.Select(room =>
new TeachingTaskAllowedExperimentClassroom { ClassroomId = room.Id }).ToList();
}
if (request.UpdatePeriodRange)
{
constraint.EarliestPeriod = request.EarliestPeriod;
@@ -402,11 +510,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
constraint.RequiresClassroom = false;
constraint.RequiredCampusId = null;
constraint.RequiredBuildingId = null;
constraint.ExperimentRequiredCampusId = null;
constraint.ExperimentRequiredBuildingId = null;
constraint.AllowedDayOfWeeks = null;
constraint.EarliestPeriod = null;
constraint.LatestPeriod = null;
db.TeachingTaskAllowedClassrooms.RemoveRange(constraint.AllowedClassrooms);
db.TeachingTaskAllowedExperimentClassrooms.RemoveRange(constraint.AllowedExperimentClassrooms);
constraint.AllowedClassrooms = [];
constraint.AllowedExperimentClassrooms = [];
}
private ActionResult ConflictProblem(string detail) =>
@@ -441,7 +553,10 @@ public sealed record TeachingTaskScheduleConstraintRequest(
IReadOnlyList<int> AllowedDayOfWeeks,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod,
TeachingVenueNature AllowedExperimentVenueNatures = 0);
TeachingVenueNature AllowedExperimentVenueNatures = 0,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
public sealed record TeachingTaskScheduleConstraintBatchRequest(
Guid AcademicTermId,
@@ -455,4 +570,9 @@ public sealed record TeachingTaskScheduleConstraintBatchRequest(
IReadOnlyList<Guid>? AllowedClassroomIds,
bool UpdatePeriodRange,
[Range(1, 30)] int? EarliestPeriod,
[Range(1, 30)] int? LatestPeriod);
[Range(1, 30)] int? LatestPeriod,
bool UpdateExperimentClassroomScope = false,
TeachingVenueNature? AllowedExperimentVenueNatures = null,
IReadOnlyList<Guid>? AllowedExperimentClassroomIds = null,
Guid? ExperimentRequiredCampusId = null,
Guid? ExperimentRequiredBuildingId = null);
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
ToResponse(job));
}
[HttpGet("plans/{planId:guid}/preflight")]
public async Task<ActionResult> Preflight(Guid planId, CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var tasks = await db.TeachingTasks.AsNoTracking()
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
x.Status == TeachingTaskStatus.Published &&
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
.Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name })
.ToListAsync(cancellationToken);
var scheduled = await db.ScheduleEntries.AsNoTracking()
.Where(x => x.SchedulePlanId == planId)
.Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken);
var missing = tasks.Where(x => !scheduled.Contains(x.Id))
.Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList();
return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing });
}
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
public async Task<ActionResult<AutomaticScheduleJobResponse>>
GetAutomaticScheduleJob(
@@ -552,6 +571,7 @@ public sealed class SchedulesController(
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken);
@@ -580,20 +600,26 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (request.Kind == ScheduleEntryKind.Experiment &&
!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
return ValidationProblem(
$"实验课必须安排在具有实验教学性质的场地;“{classroom.Name}”未标注实验室、实训室、机房或语音室性质。 ");
if (constraint?.RequiredCampusId is Guid campusId &&
if (request.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
if (constraint?.RequiredBuildingId is Guid buildingId &&
if (request.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId)
return ValidationProblem("所选教室不在该课程指定的教学楼。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
classroom.Building!.CampusId != experimentCampusId)
return ValidationProblem("所选场地不在该实验课指定的校区。");
if (request.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
classroom.BuildingId != experimentBuildingId)
return ValidationProblem("所选场地不在该实验课指定的教学楼。");
var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 &&
if (request.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选教室不在该课程指定的教室范围内。");
if (request.Kind == ScheduleEntryKind.Experiment &&
@@ -601,6 +627,13 @@ public sealed class SchedulesController(
allowedNatures != 0 &&
(classroom.TeachingVenueNature & allowedNatures) == 0)
return ValidationProblem("所选场地不在该实验课允许的教学场地性质范围内。");
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (request.Kind == ScheduleEntryKind.Experiment &&
allowedExperimentClassroomIds.Count > 0 &&
!allowedExperimentClassroomIds.Contains(classroom.Id))
return ValidationProblem("所选场地不在该实验课指定的场地范围内。");
}
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
@@ -21,32 +21,45 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetRules(Guid academicTermId, CancellationToken ct) =>
Ok(await db.WarningRules.AsNoTracking().Where(x => x.AcademicTermId == academicTermId)
.OrderBy(x => x.Type).Select(x => new { x.Id, x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
.OrderBy(x => x.Type).Select(x => new { x.Id, Type = (int)x.Type, x.Name, x.Threshold, x.IsEnabled, x.NotifyStudent, x.NotifyCounselor, x.Description, x.AutoCheckEnabled, CheckDayOfWeek = x.CheckDayOfWeek ?? 0, x.CheckHour, x.CheckMinute, x.LastCheckAt })
.ToListAsync(ct));
[HttpPut("rules")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SaveRules(Guid academicTermId, List<WarningRuleDto> rules, CancellationToken ct)
{
if (rules.GroupBy(x => x.Type).Any(group => group.Count() > 1))
return BadRequest(new ProblemDetails { Title = "预警类型不能重复。", Status = StatusCodes.Status400BadRequest });
if (rules.Any(x => x.CheckDayOfWeek is < 0 or > 7 || x.CheckHour is < 0 or > 23 || x.CheckMinute is < 0 or > 59))
return BadRequest(new ProblemDetails { Title = "自动检测时间无效。", Status = StatusCodes.Status400BadRequest });
var existing = await db.WarningRules.Where(x => x.AcademicTermId == academicTermId).ToListAsync(ct);
db.WarningRules.RemoveRange(existing);
var incomingTypes = rules.Select(x => x.Type).ToHashSet();
db.WarningRules.RemoveRange(existing.Where(x => !incomingTypes.Contains(x.Type)));
foreach (var r in rules)
{
db.WarningRules.Add(new WarningRule
var entity = existing.FirstOrDefault(x => x.Type == r.Type);
if (entity is null)
{
AcademicTermId = academicTermId,
Type = r.Type,
Name = r.Name.Trim(),
Threshold = r.Threshold,
IsEnabled = r.IsEnabled,
NotifyStudent = r.NotifyStudent,
NotifyCounselor = r.NotifyCounselor,
Description = r.Description?.Trim(),
AutoCheckEnabled = r.AutoCheckEnabled,
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
CheckHour = r.CheckHour,
CheckMinute = r.CheckMinute
});
entity = new WarningRule
{
AcademicTermId = academicTermId,
Type = r.Type,
Name = r.Name.Trim()
};
db.WarningRules.Add(entity);
}
entity.Name = r.Name.Trim();
entity.Threshold = r.Threshold;
entity.IsEnabled = r.IsEnabled;
entity.NotifyStudent = r.NotifyStudent;
entity.NotifyCounselor = r.NotifyCounselor;
entity.Description = r.Description?.Trim();
entity.AutoCheckEnabled = r.AutoCheckEnabled;
entity.CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek;
entity.CheckHour = r.CheckHour;
entity.CheckMinute = r.CheckMinute;
}
await db.SaveChangesAsync(ct);
return NoContent();
@@ -120,7 +133,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
}
if (academicTermId.HasValue) q = q.Where(x => x.AcademicTermId == academicTermId);
if (type.HasValue) q = q.Where(x => x.Type == type);
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
return Ok(await q.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.StudentId, StudentName = x.Student!.Name, StudentNumber = x.Student.StudentNumber, ClassName = x.Student.AdministrativeClass!.Name, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt }).ToListAsync(ct));
}
// ═══════════ Student ═══════════
@@ -131,7 +144,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
var sid = await GetStudentIdAsync(ct);
if (sid is null) return StudentNotFound();
return Ok(await db.WarningRecords.AsNoTracking().Where(x => x.StudentId == sid).OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Id, x.Type, x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
.Select(x => new { x.Id, Type = (int)x.Type, Status = (int)x.Status, x.TriggerValue, x.Detail, x.AcknowledgedAt, x.AcknowledgeComment, x.CreatedAt, TermName = x.AcademicTermId })
.ToListAsync(ct));
}
@@ -0,0 +1,14 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class CourseGradeStatisticsRefreshSetting : EntityBase
{
public const string DefaultKey = "default";
public string Key { get; set; } = DefaultKey;
public bool IsEnabled { get; set; } = true;
public int IntervalSeconds { get; set; } = 300;
public int BatchSize { get; set; } = 100;
public DateTime? LastRunAt { get; set; }
}
@@ -38,6 +38,22 @@ public sealed class CurriculumCourse : EntityBase
public string? Notes { get; set; }
}
public sealed class CourseGroup : EntityBase
{
public required string Code { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public ICollection<CourseGroupCourse> Courses { get; set; } = [];
}
public sealed class CourseGroupCourse : EntityBase
{
public Guid CourseGroupId { get; set; }
public CourseGroup? CourseGroup { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
}
public enum CurriculumPlanStatus
{
Draft = 1,
@@ -20,6 +20,7 @@ public sealed class ExamArrangementJob : EntityBase
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? ProjectIdsJson { get; set; }
public string? SessionIdsJson { get; set; }
public bool AssignClassrooms { get; set; }
public bool AssignInvigilators { get; set; }
@@ -138,6 +139,7 @@ public sealed class ExamPublishJob : EntityBase
public Guid PlanId { get; set; }
public Guid? ActivePlanId { get; set; }
public Guid? RequestedByUserId { get; set; }
public string? ProjectIdsJson { get; set; }
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
public string? CurrentStep { get; set; }
public string? ErrorMessage { get; set; }
@@ -148,7 +150,8 @@ public sealed class ExamPublishJob : EntityBase
public enum ExamPublishJobKind
{
FormalExam = 1,
MakeupExam = 2
MakeupExam = 2,
ExperimentProjects = 3
}
public enum ExamPublishJobStatus
@@ -9,6 +9,8 @@ public sealed class ExperimentProject : EntityBase
// 集中安排的项目复用已发布课表中的实验课,不再维护一份重复的场次。
public Guid? ScheduleEntryId { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
// 集中安排按课表的具体周次拆分为实验项目;自行安排为空。
public int? ScheduleWeek { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public ExperimentArrangementMode ArrangementMode { get; set; }
@@ -51,6 +51,22 @@ public sealed class ExperimentGradeRecord : EntityBase
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
}
/// <summary>
/// A student's persisted experiment-part score for one teaching task.
/// The score is the weighted average of every published experiment project.
/// </summary>
public sealed class ExperimentCourseGrade : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? WeightedAverageScore { get; set; }
public decimal TotalWeight { get; set; }
public int PublishedProjectCount { get; set; }
public DateTime RefreshedAt { get; set; }
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
@@ -52,11 +52,16 @@ public sealed class TeachingTaskScheduleConstraint : EntityBase
public Campus? RequiredCampus { get; set; }
public Guid? RequiredBuildingId { get; set; }
public Building? RequiredBuilding { get; set; }
public Guid? ExperimentRequiredCampusId { get; set; }
public Campus? ExperimentRequiredCampus { get; set; }
public Guid? ExperimentRequiredBuildingId { get; set; }
public Building? ExperimentRequiredBuilding { get; set; }
public string? AllowedDayOfWeeks { get; set; }
public int? EarliestPeriod { get; set; }
public int? LatestPeriod { get; set; }
public TeachingVenueNature AllowedExperimentVenueNatures { get; set; }
public ICollection<TeachingTaskAllowedClassroom> AllowedClassrooms { get; set; } = [];
public ICollection<TeachingTaskAllowedExperimentClassroom> AllowedExperimentClassrooms { get; set; } = [];
}
public sealed class TeachingTaskAllowedClassroom
@@ -67,6 +72,31 @@ public sealed class TeachingTaskAllowedClassroom
public Classroom? Classroom { get; set; }
}
public sealed class PublishedScheduleOccurrence : EntityBase
{
public Guid SchedulePlanId { get; set; }
public Guid AcademicTermId { get; set; }
public Guid ScheduleEntryId { get; set; }
public Guid TeachingTaskId { get; set; }
public Guid? ClassroomId { get; set; }
public int Week { get; set; }
public int DayOfWeek { get; set; }
public int StartPeriod { get; set; }
public int PeriodCount { get; set; }
public ScheduleEntryKind Kind { get; set; }
public ScheduleEntry? ScheduleEntry { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class TeachingTaskAllowedExperimentClassroom
{
public Guid TeachingTaskScheduleConstraintId { get; set; }
public TeachingTaskScheduleConstraint? TeachingTaskScheduleConstraint { get; set; }
public Guid ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
}
public sealed class AutomaticScheduleJob : EntityBase
{
public Guid SchedulePlanId { get; set; }
@@ -1,8 +1,11 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Jiaowu.Api.Infrastructure.Exams;
@@ -39,6 +42,9 @@ public sealed class ExamPublishJobProcessor(
case ExamPublishJobKind.MakeupExam:
await PublishMakeupExamAsync(job, stoppingToken);
break;
case ExamPublishJobKind.ExperimentProjects:
await PublishExperimentProjectsAsync(job, stoppingToken);
break;
default:
throw new InvalidOperationException(
$"不支持的考试发布类型:{job.Kind}。");
@@ -248,6 +254,58 @@ public sealed class ExamPublishJobProcessor(
await db.SaveChangesAsync(ct);
}
private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
{
var ids = JsonSerializer.Deserialize<List<Guid>>(job.ProjectIdsJson ?? "[]")?
.Where(x => x != Guid.Empty).Distinct().ToList() ?? [];
if (ids.Count is 0 or > 100)
throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。");
var projects = await db.ExperimentProjects
.Include(x => x.Sessions)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.Where(x => ids.Contains(x.Id))
.ToListAsync(ct);
if (projects.Count != ids.Count)
throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。");
foreach (var project in projects)
{
if (project.Status != ExperimentProjectStatus.Draft)
throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。");
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
if (!hasSchedule)
throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。");
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。");
}
job.CurrentStep = "正在发布实验项目";
await db.SaveChangesAsync(ct);
var publishedAt = DateTime.UtcNow;
foreach (var project in projects)
{
project.Status = ExperimentProjectStatus.Published;
project.PublishedAt = publishedAt;
}
await db.SaveChangesAsync(ct);
foreach (var project in projects)
{
var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId)
.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct);
if (userIds.Count == 0) continue;
var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约";
await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布",
$"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。",
"/experiments", ct, NotificationCategory.Schedule);
}
}
private async Task MarkFailedAsync(Guid jobId, string message)
{
db.ChangeTracker.Clear();
@@ -0,0 +1,228 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
/// <summary>
/// Repairs missing or stale materialized grade statistics on a database-
/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
/// worker batches changes made during bulk imports.
/// </summary>
public sealed class CourseGradeStatisticsRefreshWorker(
IServiceScopeFactory scopeFactory,
TimeProvider timeProvider,
ILogger<CourseGradeStatisticsRefreshWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Database-configured course grade statistics scheduler started.");
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), timeProvider);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var scheduler = scope.ServiceProvider
.GetRequiredService<CourseGradeStatisticsRefreshScheduler>();
var queued = await scheduler.EnqueueDueAsync(
timeProvider.GetUtcNow().UtcDateTime,
stoppingToken);
if (queued > 0)
logger.LogInformation(
"Scheduled course grade statistics scan queued {Count} refresh jobs.",
queued);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Scheduled course grade statistics scan failed.");
}
if (!await timer.WaitForNextTickAsync(stoppingToken)) break;
}
}
}
public sealed class CourseGradeStatisticsRefreshScheduler(
AppDbContext db,
ILogger<CourseGradeStatisticsRefreshScheduler> logger)
{
public async Task<int> EnqueueDueAsync(
DateTime utcNow,
CancellationToken cancellationToken) =>
await ExecuteWithLeaseAsync(async () =>
{
var setting = await db.CourseGradeStatisticsRefreshSettings
.SingleOrDefaultAsync(
x => x.Key == CourseGradeStatisticsRefreshSetting.DefaultKey,
cancellationToken);
if (setting is null)
{
setting = new CourseGradeStatisticsRefreshSetting();
db.CourseGradeStatisticsRefreshSettings.Add(setting);
}
var interval = TimeSpan.FromSeconds(
Math.Clamp(setting.IntervalSeconds, 10, 86400));
if (!setting.IsEnabled ||
setting.LastRunAt.HasValue && utcNow < setting.LastRunAt.Value + interval)
{
if (db.Entry(setting).State == EntityState.Added)
await db.SaveChangesAsync(cancellationToken);
return 0;
}
setting.LastRunAt = utcNow;
var queued = await EnqueueStaleCoreAsync(setting.BatchSize, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
return queued;
}, cancellationToken);
public async Task<int> EnqueueStaleAsync(
int batchSize,
CancellationToken cancellationToken) =>
await ExecuteWithLeaseAsync(async () =>
{
var queued = await EnqueueStaleCoreAsync(batchSize, cancellationToken);
if (queued > 0) await db.SaveChangesAsync(cancellationToken);
return queued;
}, cancellationToken);
private async Task<int> ExecuteWithLeaseAsync(
Func<Task<int>> action,
CancellationToken cancellationToken)
{
var usesMySqlLease = db.Database.ProviderName?.Contains(
"MySql",
StringComparison.OrdinalIgnoreCase) == true;
if (usesMySqlLease && !await TryAcquireMySqlLeaseAsync(cancellationToken))
{
await db.Database.CloseConnectionAsync();
logger.LogDebug("Another instance owns the grade statistics refresh lease.");
return 0;
}
try
{
return await action();
}
finally
{
if (usesMySqlLease)
await ReleaseMySqlLeaseAsync();
}
}
private async Task<int> EnqueueStaleCoreAsync(
int batchSize,
CancellationToken cancellationToken)
{
batchSize = Math.Clamp(batchSize, 1, 5000);
var activeTargets = await (
from job in db.CourseGradeStatisticsRefreshJobs.AsNoTracking()
join sheet in db.GradeSheets.AsNoTracking()
on job.GradeSheetId equals sheet.Id
where job.Status == CourseGradeStatisticsRefreshJobStatus.Queued ||
job.Status == CourseGradeStatisticsRefreshJobStatus.Running
select new CourseTermTarget(
sheet.TeachingTask!.CourseId,
sheet.TeachingTask.AcademicTermId))
.Distinct()
.ToListAsync(cancellationToken);
var active = activeTargets.ToHashSet();
var rows = await db.GradeSheets.AsNoTracking()
.Where(sheet =>
sheet.Status == GradeSheetStatus.Published &&
sheet.Records.Any(record => record.TotalScore != null))
.Select(sheet => new RefreshCandidate(
sheet.Id,
sheet.TeachingTask!.CourseId,
sheet.TeachingTask.AcademicTermId,
sheet.UpdatedAt,
sheet.Records
.Where(record => record.TotalScore != null)
.Max(record => record.UpdatedAt),
db.TeachingTaskGradeStatistics
.Where(statistic => statistic.GradeSheetId == sheet.Id)
.Select(statistic => (DateTime?)statistic.CalculatedAt)
.FirstOrDefault()))
.ToListAsync(cancellationToken);
var stale = rows
.Where(row =>
row.CalculatedAt is null ||
row.SheetUpdatedAt > row.CalculatedAt ||
row.RecordsUpdatedAt > row.CalculatedAt)
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
.Where(group => !active.Contains(group.Key))
.Select(group => group
.OrderByDescending(row => row.RecordsUpdatedAt)
.ThenByDescending(row => row.SheetUpdatedAt)
.First())
.Take(batchSize)
.ToArray();
foreach (var candidate in stale)
{
var job = new CourseGradeStatisticsRefreshJob
{
GradeSheetId = candidate.GradeSheetId
};
db.CourseGradeStatisticsRefreshJobs.Add(job);
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
BackgroundJobKind.CourseGradeStatisticsRefresh,
job.Id));
}
if (stale.Length == 0) return 0;
logger.LogDebug(
"Queued {Count} stale course grade statistics targets.",
stale.Length);
return stale.Length;
}
private async Task<bool> TryAcquireMySqlLeaseAsync(
CancellationToken cancellationToken)
{
await db.Database.OpenConnectionAsync(cancellationToken);
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT GET_LOCK('jiaowu:grade-statistics-refresh', 0);";
var result = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt32(result) == 1;
}
private async Task ReleaseMySqlLeaseAsync()
{
try
{
await using var command = db.Database.GetDbConnection().CreateCommand();
command.CommandText = "SELECT RELEASE_LOCK('jiaowu:grade-statistics-refresh');";
await command.ExecuteScalarAsync(CancellationToken.None);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to release grade statistics refresh lease.");
}
finally
{
await db.Database.CloseConnectionAsync();
}
}
private sealed record RefreshCandidate(
Guid GradeSheetId,
Guid CourseId,
Guid AcademicTermId,
DateTime SheetUpdatedAt,
DateTime RecordsUpdatedAt,
DateTime? CalculatedAt);
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
}
@@ -5,13 +5,84 @@ namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task RefreshTeachingTaskAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var existing = await db.ExperimentCourseGrades
.Where(x => x.TeachingTaskId == teachingTaskId)
.ToDictionaryAsync(x => x.StudentId, cancellationToken);
var studentIds = sheets
.SelectMany(x => x.Scores.Select(score => score.StudentId))
.Distinct()
.ToArray();
var refreshedAt = DateTime.UtcNow;
var totalWeight = sheets.Sum(x => x.ContributionWeight);
foreach (var studentId in studentIds)
{
var score = CalculateStudentScore(sheets, studentId);
if (!existing.Remove(studentId, out var aggregate))
{
aggregate = new Domain.Academic.ExperimentCourseGrade
{
TeachingTaskId = teachingTaskId,
StudentId = studentId
};
db.ExperimentCourseGrades.Add(aggregate);
}
aggregate.WeightedAverageScore = score;
aggregate.TotalWeight = totalWeight;
aggregate.PublishedProjectCount = sheets.Count;
aggregate.RefreshedAt = refreshedAt;
}
db.ExperimentCourseGrades.RemoveRange(existing.Values);
await db.SaveChangesAsync(cancellationToken);
}
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
var sheets = await LoadPublishedSheetsAsync(
db,
teachingTaskId,
cancellationToken);
var requestedStudentIds = studentIds.Distinct().ToArray();
var persistedScores = await db.ExperimentCourseGrades.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId)
.WhereIn(requestedStudentIds, x => x.StudentId)
.ToDictionaryAsync(
x => x.StudentId,
x => x.WeightedAverageScore,
cancellationToken);
var scores = requestedStudentIds.ToDictionary(
studentId => studentId,
studentId => persistedScores.GetValueOrDefault(studentId));
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private static Task<List<PublishedExperimentSheet>> LoadPublishedSheetsAsync(
AppDbContext db,
Guid teachingTaskId,
CancellationToken cancellationToken) =>
db.ExperimentGradeSheets.AsNoTracking()
.Where(x =>
x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published &&
@@ -28,40 +99,27 @@ public static class ExperimentGradeAggregationService
.AsSplitQuery()
.ToListAsync(cancellationToken);
var scores = new Dictionary<Guid, decimal?>();
foreach (var studentId in studentIds.Distinct())
private static decimal? CalculateStudentScore(
IReadOnlyCollection<PublishedExperimentSheet> sheets,
Guid studentId)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
if (sheets.Count == 0) return null;
foreach (var sheet in sheets)
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
var complete = sheets.Count > 0;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore)
{
complete = false;
break;
}
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
scores[studentId] = complete && totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore) return null;
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
return totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
private sealed record PublishedExperimentSheet(
@@ -51,7 +51,8 @@ public static class GradeAnalysisWordReportGenerator
DateTime generatedAt,
HeaderFooterIds headerFooterIds)
{
var body = mainPart.Document.Body!;
var body = mainPart.Document?.Body
?? throw new InvalidOperationException("The report document body has not been initialized.");
var summary = report.Summary!;
body.Append(Paragraph("成绩分析报告", 46, true, "000000", 0, 80));
@@ -467,9 +468,10 @@ public static class GradeAnalysisWordReportGenerator
{
using var paint = new SKPaint { Color = color, StrokeWidth = 4, IsAntialias = true, Style = SKPaintStyle.Stroke };
using var fill = new SKPaint { Color = color, IsAntialias = true };
using var path = new SKPath();
path.MoveTo(points[0]);
foreach (var point in points.Skip(1)) path.LineTo(point);
using var builder = new SKPathBuilder();
builder.MoveTo(points[0]);
foreach (var point in points.Skip(1)) builder.LineTo(point);
using var path = builder.Detach();
canvas.DrawPath(path, paint);
for (var i = 0; i < points.Length; i++)
{
@@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
public DbSet<CourseGroup> CourseGroups => Set<CourseGroup>();
public DbSet<CourseGroupCourse> CourseGroupCourses => Set<CourseGroupCourse>();
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
@@ -33,11 +35,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<TeacherCourseApplication>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<PublishedScheduleOccurrence> PublishedScheduleOccurrences => Set<PublishedScheduleOccurrence>();
public DbSet<ScheduleTimeSlot> ScheduleTimeSlots => Set<ScheduleTimeSlot>();
public DbSet<TeachingTaskScheduleConstraint> TeachingTaskScheduleConstraints =>
Set<TeachingTaskScheduleConstraint>();
public DbSet<TeachingTaskAllowedClassroom> TeachingTaskAllowedClassrooms =>
Set<TeachingTaskAllowedClassroom>();
public DbSet<TeachingTaskAllowedExperimentClassroom> TeachingTaskAllowedExperimentClassrooms =>
Set<TeachingTaskAllowedExperimentClassroom>();
public DbSet<AutomaticScheduleJob> AutomaticScheduleJobs =>
Set<AutomaticScheduleJob>();
public DbSet<SchedulePublishJob> SchedulePublishJobs =>
@@ -53,6 +58,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>();
public DbSet<ExperimentCourseGrade> ExperimentCourseGrades =>
Set<ExperimentCourseGrade>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
@@ -139,6 +146,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<AppUpdateRelease>();
public DbSet<SystemFeatureSetting> SystemFeatureSettings =>
Set<SystemFeatureSetting>();
public DbSet<CourseGradeStatisticsRefreshSetting> CourseGradeStatisticsRefreshSettings =>
Set<CourseGradeStatisticsRefreshSetting>();
public DbSet<RefreshSession> RefreshSessions => Set<RefreshSession>();
protected override void ConfigureConventions(
@@ -511,6 +520,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.WithMany()
.HasForeignKey(x => x.RequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredCampus)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredCampusId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentRequiredBuilding)
.WithMany()
.HasForeignKey(x => x.ExperimentRequiredBuildingId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskAllowedClassroom>(entity =>
@@ -530,6 +547,57 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CourseGroup>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.Description).HasMaxLength(500);
entity.HasIndex(x => x.Code).IsUnique();
});
builder.Entity<CourseGroupCourse>(entity =>
{
entity.HasIndex(x => new { x.CourseGroupId, x.CourseId }).IsUnique();
entity.HasOne(x => x.CourseGroup)
.WithMany(x => x.Courses)
.HasForeignKey(x => x.CourseGroupId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<PublishedScheduleOccurrence>(entity =>
{
entity.HasIndex(x => new { x.AcademicTermId, x.TeachingTaskId, x.Week });
entity.HasIndex(x => new
{
x.AcademicTermId,
x.Week,
x.DayOfWeek,
x.StartPeriod,
x.ClassroomId
});
entity.HasIndex(x => new { x.SchedulePlanId, x.ClassroomId, x.Week, x.DayOfWeek, x.StartPeriod });
entity.HasIndex(x => new { x.ScheduleEntryId, x.Week }).IsUnique();
entity.HasOne(x => x.ScheduleEntry).WithMany().HasForeignKey(x => x.ScheduleEntryId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.TeachingTask).WithMany().HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Classroom).WithMany().HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<TeachingTaskAllowedExperimentClassroom>(entity =>
{
entity.HasKey(x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
entity.HasOne(x => x.TeachingTaskScheduleConstraint)
.WithMany(x => x.AllowedExperimentClassrooms)
.HasForeignKey(x => x.TeachingTaskScheduleConstraintId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Classroom)
.WithMany()
.HasForeignKey(x => x.ClassroomId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AutomaticScheduleJob>(entity =>
{
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
@@ -614,7 +682,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.Property(x => x.Requirements).HasMaxLength(1000);
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
// 集中安排会为同一教学任务的每一条实验课表记录生成项目;
// 自行安排仍由控制器保持“教学任务 + 编码”唯一。
entity.HasIndex(x => new
{
x.TeachingTaskId,
x.Code,
x.ScheduleEntryId,
x.ScheduleWeek
})
.IsUnique();
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
@@ -720,6 +797,21 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentCourseGrade>(entity =>
{
entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 1);
entity.Property(x => x.TotalWeight).HasPrecision(8, 1);
entity.HasIndex(x => new { x.TeachingTaskId, x.StudentId })
.IsUnique();
entity.HasIndex(x => x.StudentId);
entity.HasOne(x => x.TeachingTask).WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
@@ -1463,6 +1555,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<CourseGradeStatisticsRefreshSetting>(entity =>
{
entity.Property(x => x.Key).HasMaxLength(50);
entity.HasIndex(x => x.Key).IsUnique();
});
builder.Entity<OfficialDocument>(entity =>
{
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
@@ -92,6 +92,16 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_48_teaching_task_grade_analytics";
private const string SwaggerDocumentationSettingMigration =
"20260809_49_swagger_documentation_setting";
private const string ExperimentClassroomConstraintsMigration =
"20260809_50_experiment_classroom_constraints";
private const string SeparateExperimentClassroomScopeMigration =
"20260809_51_separate_experiment_classroom_scope";
private const string ReusableCourseGroupsMigration =
"20260809_52_reusable_course_groups";
private const string CourseGradeStatisticsRefreshSettingsMigration =
"20260809_53_course_grade_statistics_refresh_settings";
private const string ExperimentCourseGradesMigration =
"20260809_54_experiment_course_grades";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -671,6 +681,14 @@ public sealed class DevelopmentSqliteMigrator(
TeachingTaskGradeAnalyticsMigration,
TeachingTaskGradeAnalyticsStatements,
cancellationToken);
var gradeRefreshSettingsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGradeStatisticsRefreshSettings'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
CourseGradeStatisticsRefreshSettingsMigration,
gradeRefreshSettingsExist ? [] : CourseGradeStatisticsRefreshSettingsStatements,
cancellationToken);
var swaggerSettingsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'SystemFeatureSettings'")
@@ -679,6 +697,40 @@ public sealed class DevelopmentSqliteMigrator(
SwaggerDocumentationSettingMigration,
swaggerSettingsExist ? [] : SwaggerDocumentationSettingStatements,
cancellationToken);
var experimentClassroomConstraintsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'TeachingTaskAllowedExperimentClassrooms'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentClassroomConstraintsMigration,
experimentClassroomConstraintsExist ? [] : ExperimentClassroomConstraintStatements,
cancellationToken);
var experimentScopeColumns = (await db.Database.SqlQueryRaw<string>(
"SELECT name AS \"Value\" FROM pragma_table_info('TeachingTaskScheduleConstraints')")
.ToListAsync(cancellationToken))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
await ApplyMigrationAsync(
SeparateExperimentClassroomScopeMigration,
experimentScopeColumns.Contains("ExperimentRequiredCampusId")
? []
: SeparateExperimentClassroomScopeStatements,
cancellationToken);
var courseGroupsExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseGroups'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ReusableCourseGroupsMigration,
courseGroupsExist ? [] : ReusableCourseGroupsStatements,
cancellationToken);
var experimentCourseGradesExist = await db.Database
.SqlQueryRaw<int>(
"SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExperimentCourseGrades'")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentCourseGradesMigration,
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2940,4 +2992,162 @@ public sealed class DevelopmentSqliteMigrator(
ON "SystemFeatureSettings" ("Key");
"""
];
private static readonly string[] CourseGradeStatisticsRefreshSettingsStatements =
[
"""CREATE TABLE IF NOT EXISTS "CourseGradeStatisticsRefreshSettings" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseGradeStatisticsRefreshSettings" PRIMARY KEY, "Key" TEXT NOT NULL, "IsEnabled" INTEGER NOT NULL, "IntervalSeconds" INTEGER NOT NULL, "BatchSize" INTEGER NOT NULL, "LastRunAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL);""",
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
];
private static readonly string[] ExperimentCourseGradesStatements =
[
"""
CREATE TABLE "ExperimentCourseGrades" (
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentCourseGrades" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"WeightedAverageScore" TEXT NULL,
"TotalWeight" TEXT NOT NULL,
"PublishedProjectCount" INTEGER NOT NULL,
"RefreshedAt" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentCourseGrades_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""CREATE INDEX "IX_ExperimentCourseGrades_StudentId" ON "ExperimentCourseGrades" ("StudentId");""",
"""CREATE UNIQUE INDEX "IX_ExperimentCourseGrades_TeachingTaskId_StudentId" ON "ExperimentCourseGrades" ("TeachingTaskId", "StudentId");""",
"""
INSERT INTO "ExperimentCourseGrades"
("Id", "TeachingTaskId", "StudentId", "WeightedAverageScore",
"TotalWeight", "PublishedProjectCount", "RefreshedAt",
"CreatedAt", "UpdatedAt")
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' ||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(lower(hex(randomblob(2))), 2) || '-' || lower(hex(randomblob(6))),
project."TeachingTaskId",
record."StudentId",
CASE
WHEN COUNT(*) = (
SELECT COUNT(*)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId")
AND SUM(CASE WHEN record."TotalScore" IS NULL THEN 1 ELSE 0 END) = 0
THEN ROUND(
SUM(record."TotalScore" * sheet."ContributionWeight") /
SUM(sheet."ContributionWeight"), 1)
ELSE NULL
END,
(
SELECT COALESCE(SUM(all_sheet."ContributionWeight"), 0)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
(
SELECT COUNT(*)
FROM "ExperimentGradeSheets" AS all_sheet
INNER JOIN "ExperimentProjects" AS all_project
ON all_project."Id" = all_sheet."ExperimentProjectId"
WHERE all_sheet."Status" = 4
AND all_project."TeachingTaskId" = project."TeachingTaskId"),
strftime('%Y-%m-%d %H:%M:%f', 'now'),
strftime('%Y-%m-%d %H:%M:%f', 'now'),
strftime('%Y-%m-%d %H:%M:%f', 'now')
FROM "ExperimentGradeRecords" AS record
INNER JOIN "ExperimentGradeSheets" AS sheet
ON sheet."Id" = record."ExperimentGradeSheetId"
INNER JOIN "ExperimentProjects" AS project
ON project."Id" = sheet."ExperimentProjectId"
WHERE sheet."Status" = 4
GROUP BY project."TeachingTaskId", record."StudentId";
"""
];
private static readonly string[] ExperimentClassroomConstraintStatements =
[
"""
CREATE TABLE "TeachingTaskAllowedExperimentClassrooms" (
"TeachingTaskScheduleConstraintId" TEXT NOT NULL,
"ClassroomId" TEXT NOT NULL,
CONSTRAINT "PK_TeachingTaskAllowedExperimentClassrooms"
PRIMARY KEY ("TeachingTaskScheduleConstraintId", "ClassroomId"),
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Constraints"
FOREIGN KEY ("TeachingTaskScheduleConstraintId")
REFERENCES "TeachingTaskScheduleConstraints" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms"
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId"
ON "TeachingTaskAllowedExperimentClassrooms" ("ClassroomId");
"""
];
private static readonly string[] SeparateExperimentClassroomScopeStatements =
[
"""
ALTER TABLE "TeachingTaskScheduleConstraints"
ADD COLUMN "ExperimentRequiredCampusId" TEXT NULL;
""",
"""
ALTER TABLE "TeachingTaskScheduleConstraints"
ADD COLUMN "ExperimentRequiredBuildingId" TEXT NULL;
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId"
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredCampusId");
""",
"""
CREATE INDEX "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId"
ON "TeachingTaskScheduleConstraints" ("ExperimentRequiredBuildingId");
"""
];
private static readonly string[] ReusableCourseGroupsStatements =
[
"""
CREATE TABLE "CourseGroups" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroups" PRIMARY KEY,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Description" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""
CREATE UNIQUE INDEX "IX_CourseGroups_Code" ON "CourseGroups" ("Code");
""",
"""
CREATE TABLE "CourseGroupCourses" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseGroupCourses" PRIMARY KEY,
"CourseGroupId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CourseGroupCourses_CourseGroups_CourseGroupId"
FOREIGN KEY ("CourseGroupId") REFERENCES "CourseGroups" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_CourseGroupCourses_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_CourseGroupCourses_CourseGroupId_CourseId"
ON "CourseGroupCourses" ("CourseGroupId", "CourseId");
""",
"""
CREATE INDEX "IX_CourseGroupCourses_CourseId" ON "CourseGroupCourses" ("CourseId");
"""
];
}
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AddExperimentClassroomConstraints : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeachingTaskAllowedExperimentClassrooms",
columns: table => new
{
TeachingTaskScheduleConstraintId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskAllowedExperimentClassrooms", x => new { x.TeachingTaskScheduleConstraintId, x.ClassroomId });
table.ForeignKey(
name: "FK_TeachingTaskAllowedExperimentClassrooms_Classrooms_Classroom~",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskAllowedExperimentClassrooms_TeachingTaskSchedule~",
column: x => x.TeachingTaskScheduleConstraintId,
principalTable: "TeachingTaskScheduleConstraints",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskAllowedExperimentClassrooms_ClassroomId",
table: "TeachingTaskAllowedExperimentClassrooms",
column: "ClassroomId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeachingTaskAllowedExperimentClassrooms");
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class SeparateExperimentClassroomScope : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints",
type: "char(36)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredBuildingId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredCampusId");
migrationBuilder.AddForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Buildings_ExperimentRequired~",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredBuildingId",
principalTable: "Buildings",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Campuses_ExperimentRequiredC~",
table: "TeachingTaskScheduleConstraints",
column: "ExperimentRequiredCampusId",
principalTable: "Campuses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Buildings_ExperimentRequired~",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropForeignKey(
name: "FK_TeachingTaskScheduleConstraints_Campuses_ExperimentRequiredC~",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropIndex(
name: "IX_TeachingTaskScheduleConstraints_ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropColumn(
name: "ExperimentRequiredBuildingId",
table: "TeachingTaskScheduleConstraints");
migrationBuilder.DropColumn(
name: "ExperimentRequiredCampusId",
table: "TeachingTaskScheduleConstraints");
}
}
}
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PublishedTimetableOccurrences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PublishedScheduleOccurrences",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
ScheduleEntryId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: true),
Week = table.Column<int>(type: "int", nullable: false),
DayOfWeek = table.Column<int>(type: "int", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
Kind = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PublishedScheduleOccurrences", x => x.Id);
table.ForeignKey(
name: "FK_PublishedScheduleOccurrences_Classrooms_ClassroomId",
column: x => x.ClassroomId,
principalTable: "Classrooms",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_PublishedScheduleOccurrences_ScheduleEntries_ScheduleEntryId",
column: x => x.ScheduleEntryId,
principalTable: "ScheduleEntries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PublishedScheduleOccurrences_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_AcademicTermId_TeachingTaskId_W~",
table: "PublishedScheduleOccurrences",
columns: new[] { "AcademicTermId", "TeachingTaskId", "Week" });
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_ClassroomId",
table: "PublishedScheduleOccurrences",
column: "ClassroomId");
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_ScheduleEntryId_Week",
table: "PublishedScheduleOccurrences",
columns: new[] { "ScheduleEntryId", "Week" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_SchedulePlanId_ClassroomId_Week~",
table: "PublishedScheduleOccurrences",
columns: new[] { "SchedulePlanId", "ClassroomId", "Week", "DayOfWeek", "StartPeriod" });
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_TeachingTaskId",
table: "PublishedScheduleOccurrences",
column: "TeachingTaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PublishedScheduleOccurrences");
}
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OptimizePublishedTimetableOccurrenceLookup : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_PublishedScheduleOccurrences_AcademicTermId_Week_DayOfWeek_S~",
table: "PublishedScheduleOccurrences",
columns: new[] { "AcademicTermId", "Week", "DayOfWeek", "StartPeriod", "ClassroomId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PublishedScheduleOccurrences_AcademicTermId_Week_DayOfWeek_S~",
table: "PublishedScheduleOccurrences");
}
}
}
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AddReusableCourseGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseGroups",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGroups", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CourseGroupCourses",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseGroupId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGroupCourses", x => x.Id);
table.ForeignKey(
name: "FK_CourseGroupCourses_CourseGroups_CourseGroupId",
column: x => x.CourseGroupId,
principalTable: "CourseGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CourseGroupCourses_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseGroupCourses_CourseGroupId_CourseId",
table: "CourseGroupCourses",
columns: new[] { "CourseGroupId", "CourseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CourseGroupCourses_CourseId",
table: "CourseGroupCourses",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_CourseGroups_Code",
table: "CourseGroups",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseGroupCourses");
migrationBuilder.DropTable(
name: "CourseGroups");
}
}
}
@@ -0,0 +1,19 @@
// <auto-generated />
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
[DbContext(typeof(AppDbContext))]
[Migration("20260809112000_AllowAllScheduledExperimentLessons")]
partial class AllowAllScheduledExperimentLessons
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
public partial class AllowAllScheduledExperimentLessons : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// MySQL 可能将旧唯一索引用于外键支撑。先提供同列的普通索引,
// 再替换业务唯一索引,避免线上迁移因外键依赖而中断。
migrationBuilder.CreateIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
table: "ExperimentProjects",
columns: new[] { "TeachingTaskId", "Code" });
migrationBuilder.DropIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code",
table: "ExperimentProjects");
migrationBuilder.CreateIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
table: "ExperimentProjects",
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
table: "ExperimentProjects");
migrationBuilder.CreateIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code",
table: "ExperimentProjects",
columns: new[] { "TeachingTaskId", "Code" },
unique: true);
migrationBuilder.DropIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code_FkSupport",
table: "ExperimentProjects");
}
}
@@ -0,0 +1,19 @@
// <auto-generated />
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
[DbContext(typeof(AppDbContext))]
[Migration("20260809114000_SplitCentralizedExperimentProjectsByWeek")]
partial class SplitCentralizedExperimentProjectsByWeek
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
public partial class SplitCentralizedExperimentProjectsByWeek : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
ExecuteWhenMissing(
migrationBuilder,
"COLUMNS",
"COLUMN_NAME = 'ScheduleWeek'",
"ALTER TABLE `ExperimentProjects` ADD COLUMN `ScheduleWeek` int NULL");
ExecuteWhenPresent(
migrationBuilder,
"STATISTICS",
"INDEX_NAME = 'IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId'",
"DROP INDEX `IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId` ON `ExperimentProjects`");
ExecuteWhenMissing(
migrationBuilder,
"STATISTICS",
"INDEX_NAME = 'IX_ExpProj_Task_Code_Entry_Week'",
"CREATE UNIQUE INDEX `IX_ExpProj_Task_Code_Entry_Week` ON `ExperimentProjects` (`TeachingTaskId`, `Code`, `ScheduleEntryId`, `ScheduleWeek`)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ExpProj_Task_Code_Entry_Week",
table: "ExperimentProjects");
migrationBuilder.CreateIndex(
name: "IX_ExperimentProjects_TeachingTaskId_Code_ScheduleEntryId",
table: "ExperimentProjects",
columns: new[] { "TeachingTaskId", "Code", "ScheduleEntryId" },
unique: true);
migrationBuilder.DropColumn(
name: "ScheduleWeek",
table: "ExperimentProjects");
}
private static void ExecuteWhenMissing(
MigrationBuilder migrationBuilder,
string informationSchemaTable,
string condition,
string command)
{
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "= 0");
}
private static void ExecuteWhenPresent(
MigrationBuilder migrationBuilder,
string informationSchemaTable,
string condition,
string command)
{
ExecuteConditionally(migrationBuilder, informationSchemaTable, condition, command, "> 0");
}
private static void ExecuteConditionally(
MigrationBuilder migrationBuilder,
string informationSchemaTable,
string condition,
string command,
string comparison)
{
migrationBuilder.Sql($"SET @jiaowu_exists = (SELECT COUNT(*) FROM `information_schema`.`{informationSchemaTable}` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'ExperimentProjects' AND {condition})");
migrationBuilder.Sql($"SET @jiaowu_sql = IF(@jiaowu_exists {comparison}, '{command}', 'SELECT 1')");
migrationBuilder.Sql("PREPARE jiaowu_migration_statement FROM @jiaowu_sql");
migrationBuilder.Sql("EXECUTE jiaowu_migration_statement");
migrationBuilder.Sql("DEALLOCATE PREPARE jiaowu_migration_statement");
}
}
@@ -0,0 +1,29 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
[DbContext(typeof(AppDbContext))]
[Migration("20260809121000_ExperimentPublishJobPayload")]
public partial class ExperimentPublishJobPayload : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectIdsJson",
table: "ExamPublishJobs",
type: "longtext",
maxLength: 5000,
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectIdsJson",
table: "ExamPublishJobs");
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseGradeStatisticsRefreshSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseGradeStatisticsRefreshSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
Key = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
IntervalSeconds = table.Column<int>(type: "int", nullable: false),
BatchSize = table.Column<int>(type: "int", nullable: false),
LastRunAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CourseGradeStatisticsRefreshSettings", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CourseGradeStatisticsRefreshSettings_Key",
table: "CourseGradeStatisticsRefreshSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CourseGradeStatisticsRefreshSettings");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExamArrangementJobPayload : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ProjectIdsJson",
table: "ExamArrangementJobs",
type: "longtext",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProjectIdsJson",
table: "ExamArrangementJobs");
}
}
}
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class PersistExperimentCourseGrades : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExperimentCourseGrades",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
WeightedAverageScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
TotalWeight = table.Column<decimal>(type: "decimal(8,1)", precision: 8, scale: 1, nullable: false),
PublishedProjectCount = table.Column<int>(type: "int", nullable: false),
RefreshedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentCourseGrades", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentCourseGrades_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentCourseGrades_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentCourseGrades_StudentId",
table: "ExperimentCourseGrades",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentCourseGrades_TeachingTaskId_StudentId",
table: "ExperimentCourseGrades",
columns: new[] { "TeachingTaskId", "StudentId" },
unique: true);
migrationBuilder.Sql(
"""
INSERT INTO `ExperimentCourseGrades`
(`Id`, `TeachingTaskId`, `StudentId`, `WeightedAverageScore`,
`TotalWeight`, `PublishedProjectCount`, `RefreshedAt`,
`CreatedAt`, `UpdatedAt`)
SELECT
UUID(),
project.`TeachingTaskId`,
record.`StudentId`,
CASE
WHEN COUNT(*) = (
SELECT COUNT(*)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`)
AND SUM(CASE WHEN record.`TotalScore` IS NULL THEN 1 ELSE 0 END) = 0
THEN ROUND(
SUM(record.`TotalScore` * sheet.`ContributionWeight`) /
SUM(sheet.`ContributionWeight`), 1)
ELSE NULL
END,
(
SELECT COALESCE(SUM(all_sheet.`ContributionWeight`), 0)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
(
SELECT COUNT(*)
FROM `ExperimentGradeSheets` AS all_sheet
INNER JOIN `ExperimentProjects` AS all_project
ON all_project.`Id` = all_sheet.`ExperimentProjectId`
WHERE all_sheet.`Status` = 4
AND all_project.`TeachingTaskId` = project.`TeachingTaskId`),
UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)
FROM `ExperimentGradeRecords` AS record
INNER JOIN `ExperimentGradeSheets` AS sheet
ON sheet.`Id` = record.`ExperimentGradeSheetId`
INNER JOIN `ExperimentProjects` AS project
ON project.`Id` = sheet.`ExperimentProjectId`
WHERE sheet.`Status` = 4
GROUP BY project.`TeachingTaskId`, record.`StudentId`;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentCourseGrades");
}
}
}
@@ -1098,6 +1098,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CourseGradeStatisticsRefreshJobs");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatisticsRefreshSetting", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("BatchSize")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("IntervalSeconds")
.HasColumnType("int");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("LastRunAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("CourseGradeStatisticsRefreshSettings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("CourseGroups");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroupCourse", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseGroupId")
.HasColumnType("char(36)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CourseId");
b.HasIndex("CourseGroupId", "CourseId")
.IsUnique();
b.ToTable("CourseGroupCourses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.Property<Guid>("Id")
@@ -1749,6 +1848,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("ProcessedSessions")
.HasColumnType("int");
b.Property<string>("ProjectIdsJson")
.HasColumnType("longtext");
b.Property<Guid?>("RequestedByUserId")
.HasColumnType("char(36)");
@@ -1851,6 +1953,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid>("PlanId")
.HasColumnType("char(36)");
b.Property<string>("ProjectIdsJson")
.HasColumnType("longtext");
b.Property<Guid?>("RequestedByUserId")
.HasColumnType("char(36)");
@@ -2158,6 +2263,48 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("ExperimentBookings");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("PublishedProjectCount")
.HasColumnType("int");
b.Property<DateTime>("RefreshedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<decimal>("TotalWeight")
.HasPrecision(8, 1)
.HasColumnType("decimal(8,1)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal?>("WeightedAverageScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("Id");
b.HasIndex("StudentId");
b.HasIndex("TeachingTaskId", "StudentId")
.IsUnique();
b.ToTable("ExperimentCourseGrades");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.Property<Guid>("Id")
@@ -2370,6 +2517,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<Guid?>("ScheduleEntryId")
.HasColumnType("char(36)");
b.Property<int?>("ScheduleWeek")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("date");
@@ -2386,11 +2536,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("ScheduleEntryId");
b.HasIndex("TeachingTaskId", "Code")
.IsUnique();
b.HasIndex("Status", "StartDate", "EndDate");
b.HasIndex("TeachingTaskId", "Code", "ScheduleEntryId", "ScheduleWeek")
.IsUnique();
b.ToTable("ExperimentProjects");
});
@@ -3499,6 +3649,66 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("OtherExamResults");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.PublishedScheduleOccurrence", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<Guid?>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("DayOfWeek")
.HasColumnType("int");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<Guid>("ScheduleEntryId")
.HasColumnType("char(36)");
b.Property<Guid>("SchedulePlanId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Week")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ClassroomId");
b.HasIndex("TeachingTaskId");
b.HasIndex("ScheduleEntryId", "Week")
.IsUnique();
b.HasIndex("AcademicTermId", "TeachingTaskId", "Week");
b.HasIndex("AcademicTermId", "Week", "DayOfWeek", "StartPeriod", "ClassroomId");
b.HasIndex("SchedulePlanId", "ClassroomId", "Week", "DayOfWeek", "StartPeriod");
b.ToTable("PublishedScheduleOccurrences");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
@@ -4102,6 +4312,21 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("TeachingTaskAllowedClassrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedExperimentClassroom", b =>
{
b.Property<Guid>("TeachingTaskScheduleConstraintId")
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId");
b.HasIndex("ClassroomId");
b.ToTable("TeachingTaskAllowedExperimentClassrooms");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{
b.Property<Guid>("TeachingTaskId")
@@ -4257,6 +4482,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int?>("EarliestPeriod")
.HasColumnType("int");
b.Property<Guid?>("ExperimentRequiredBuildingId")
.HasColumnType("char(36)");
b.Property<Guid?>("ExperimentRequiredCampusId")
.HasColumnType("char(36)");
b.Property<int?>("LatestPeriod")
.HasColumnType("int");
@@ -4277,6 +4508,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasKey("Id");
b.HasIndex("ExperimentRequiredBuildingId");
b.HasIndex("ExperimentRequiredCampusId");
b.HasIndex("RequiredBuildingId");
b.HasIndex("RequiredCampusId");
@@ -5167,6 +5402,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.IsRequired();
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroupCourse", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseGroup", "CourseGroup")
.WithMany("Courses")
.HasForeignKey("CourseGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Course");
b.Navigation("CourseGroup");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
@@ -5591,6 +5845,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentCourseGrade", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Student");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
@@ -6021,6 +6294,32 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.PublishedScheduleOccurrence", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Jiaowu.Api.Domain.Academic.ScheduleEntry", "ScheduleEntry")
.WithMany()
.HasForeignKey("ScheduleEntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("ScheduleEntry");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
@@ -6198,6 +6497,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTaskScheduleConstraint");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedExperimentClassroom", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom")
.WithMany()
.HasForeignKey("ClassroomId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint")
.WithMany("AllowedExperimentClassrooms")
.HasForeignKey("TeachingTaskScheduleConstraintId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Classroom");
b.Navigation("TeachingTaskScheduleConstraint");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
@@ -6261,6 +6579,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "ExperimentRequiredBuilding")
.WithMany()
.HasForeignKey("ExperimentRequiredBuildingId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "ExperimentRequiredCampus")
.WithMany()
.HasForeignKey("ExperimentRequiredCampusId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding")
.WithMany()
.HasForeignKey("RequiredBuildingId")
@@ -6277,6 +6605,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExperimentRequiredBuilding");
b.Navigation("ExperimentRequiredCampus");
b.Navigation("RequiredBuilding");
b.Navigation("RequiredCampus");
@@ -6406,6 +6738,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("RequiredByCourses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGroup", b =>
{
b.Navigation("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Navigation("Enrollments");
@@ -6585,6 +6922,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b =>
{
b.Navigation("AllowedClassrooms");
b.Navigation("AllowedExperimentClassrooms");
});
#pragma warning restore 612, 618
}
@@ -44,6 +44,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
var classrooms = await db.Classrooms.AsNoTracking()
.Where(x => x.IsEnabled)
@@ -253,8 +254,15 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
var dayLoad = entries.Count(x => x.DayOfWeek == day);
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
var experimentGeneralClassroomPenalty =
kind == ScheduleEntryKind.Experiment &&
constraint?.AllowedExperimentVenueNatures == 0 &&
room?.TeachingVenueNature == TeachingVenueNature.GeneralClassroom
? 100_000
: 0;
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
roomWaste / 10 + startWeek;
roomWaste / 10 + startWeek +
experimentGeneralClassroomPenalty;
candidates.Add((proposed, score));
}
}
@@ -279,6 +287,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var allowedRoomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
var allowedExperimentRoomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
var minimumCapacity = Math.Max(
task.Capacity,
task.Classes.Sum(x =>
@@ -286,16 +297,25 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
student.Status == StudentStatus.Active) ?? 0));
return classrooms.Where(room =>
room.Capacity >= minimumCapacity &&
(constraint?.RequiredCampusId is not Guid requiredCampusId ||
(kind == ScheduleEntryKind.Experiment ||
constraint?.RequiredCampusId is not Guid requiredCampusId ||
room.Building!.CampusId == requiredCampusId) &&
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
(kind == ScheduleEntryKind.Experiment ||
constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
room.BuildingId == requiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
(kind != ScheduleEntryKind.Experiment ||
TeachingVenueNatureRules.SupportsExperiment(room.TeachingVenueNature)) &&
constraint?.ExperimentRequiredCampusId is not Guid experimentCampusId ||
room.Building!.CampusId == experimentCampusId) &&
(kind != ScheduleEntryKind.Experiment ||
constraint?.ExperimentRequiredBuildingId is not Guid experimentBuildingId ||
room.BuildingId == experimentBuildingId) &&
(kind == ScheduleEntryKind.Experiment ||
allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
(kind != ScheduleEntryKind.Experiment || constraint is null ||
constraint.AllowedExperimentVenueNatures == 0 ||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0))
constraint.AllowedExperimentVenueNatures == 0 ||
(room.TeachingVenueNature & constraint.AllowedExperimentVenueNatures) != 0) &&
(kind != ScheduleEntryKind.Experiment || allowedExperimentRoomIds.Count == 0 ||
allowedExperimentRoomIds.Contains(room.Id)))
.ToList();
}
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.EntityFrameworkCore;
@@ -73,6 +74,8 @@ public sealed class SchedulePublishJobProcessor(
foreach (var oldPlan in previous)
oldPlan.Status = SchedulePlanStatus.Archived;
await new PublishedTimetableProjectionService(db)
.RebuildAsync(publishPlan, stoppingToken);
publishPlan.Status = SchedulePlanStatus.Published;
publishPlan.PublishedAt = DateTime.UtcNow;
publishJob.Status = SchedulePublishJobStatus.Succeeded;
@@ -167,6 +170,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId)
.Include(x => x.AllowedClassrooms)
.Include(x => x.AllowedExperimentClassrooms)
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
foreach (var entry in plan.Entries)
@@ -270,21 +274,40 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
{
if (classroom is null || !classroom.IsEnabled)
Fail(entry, "所选教室不存在或已停用");
if (entry.Kind == ScheduleEntryKind.Experiment &&
!IsExperimentRoom(classroom.RoomType))
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
if (constraint?.RequiredCampusId is Guid campusId &&
if (entry.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
Fail(entry, "所选教室不在指定校区");
if (constraint?.RequiredBuildingId is Guid buildingId &&
if (entry.Kind != ScheduleEntryKind.Experiment &&
constraint?.RequiredBuildingId is Guid buildingId &&
classroom.BuildingId != buildingId)
Fail(entry, "所选教室不在指定教学楼");
if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredCampusId is Guid experimentCampusId &&
classroom.Building!.CampusId != experimentCampusId)
Fail(entry, "所选场地不在实验课指定校区");
if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.ExperimentRequiredBuildingId is Guid experimentBuildingId &&
classroom.BuildingId != experimentBuildingId)
Fail(entry, "所选场地不在实验课指定教学楼");
var allowedClassroomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (allowedClassroomIds.Count > 0 &&
if (entry.Kind != ScheduleEntryKind.Experiment && allowedClassroomIds.Count > 0 &&
!allowedClassroomIds.Contains(classroom.Id))
Fail(entry, "所选教室不在指定教室范围内");
var allowedExperimentClassroomIds = constraint?.AllowedExperimentClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
if (entry.Kind == ScheduleEntryKind.Experiment &&
allowedExperimentClassroomIds.Count > 0 &&
!allowedExperimentClassroomIds.Contains(classroom.Id))
Fail(entry, "所选场地不在实验课指定场地范围内");
if (entry.Kind == ScheduleEntryKind.Experiment &&
constraint?.AllowedExperimentVenueNatures is { } allowedNatures &&
allowedNatures != 0 &&
(classroom.TeachingVenueNature & allowedNatures) == 0)
Fail(entry, "所选场地不在实验课允许的场地性质范围内");
}
var studentCount = task.Classes.Sum(x =>
@@ -305,12 +328,6 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
.Select(int.Parse)
.ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
[DoesNotReturn]
private static void Fail(ScheduleEntry entry, string message)
{
@@ -17,33 +17,34 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
var occupiedIds = new HashSet<Guid>();
var (week, dayOfWeek) = ResolveTeachingWeek(term, reservationDate);
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(entry =>
entry.ClassroomId.HasValue &&
entry.SchedulePlan!.AcademicTermId == term.Id &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.DayOfWeek == dayOfWeek &&
entry.StartWeek <= week &&
entry.EndWeek >= week &&
entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => new
{
entry.ClassroomId,
entry.WeekPattern,
entry.StartPeriod,
entry.PeriodCount
})
.ToListAsync(cancellationToken);
foreach (var entry in scheduleEntries.Where(entry =>
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(
startPeriod,
periodCount,
entry.StartPeriod,
entry.PeriodCount)))
var hasProjection = await db.PublishedScheduleOccurrences.AsNoTracking()
.AnyAsync(entry => entry.AcademicTermId == term.Id, cancellationToken);
if (hasProjection)
{
occupiedIds.Add(entry.ClassroomId!.Value);
var projectedRoomIds = await db.PublishedScheduleOccurrences.AsNoTracking()
.Where(entry => entry.AcademicTermId == term.Id && entry.Week == week &&
entry.DayOfWeek == dayOfWeek && entry.ClassroomId.HasValue &&
entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => entry.ClassroomId!.Value)
.ToListAsync(cancellationToken);
occupiedIds.UnionWith(projectedRoomIds);
}
else
{
var scheduleEntries = await db.ScheduleEntries.AsNoTracking()
.Where(entry => entry.ClassroomId.HasValue &&
entry.SchedulePlan!.AcademicTermId == term.Id &&
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
entry.DayOfWeek == dayOfWeek && entry.StartWeek <= week &&
entry.EndWeek >= week && entry.StartPeriod < startPeriod + periodCount &&
startPeriod < entry.StartPeriod + entry.PeriodCount)
.Select(entry => new { entry.ClassroomId, entry.WeekPattern, entry.StartPeriod, entry.PeriodCount })
.ToListAsync(cancellationToken);
foreach (var entry in scheduleEntries.Where(entry =>
FreeClassroomRules.MatchesWeek(entry.WeekPattern, week) &&
FreeClassroomRules.PeriodsOverlap(startPeriod, periodCount, entry.StartPeriod, entry.PeriodCount)))
occupiedIds.Add(entry.ClassroomId!.Value);
}
var examRoomIds = await db.ExamSessions.AsNoTracking()
@@ -0,0 +1,63 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Timetables;
public sealed class PublishedTimetableProjectionService(AppDbContext db)
{
private const int WriteBatchSize = 2_000;
public async Task RebuildPublishedPlansForTaskAsync(Guid teachingTaskId, CancellationToken cancellationToken)
{
var plans = await db.SchedulePlans
.Where(plan => plan.Status == SchedulePlanStatus.Published &&
plan.Entries.Any(entry => entry.TeachingTaskId == teachingTaskId))
.Include(plan => plan.Entries)
.ToListAsync(cancellationToken);
foreach (var plan in plans)
await RebuildAsync(plan, cancellationToken);
}
public async Task RebuildAsync(SchedulePlan plan, CancellationToken cancellationToken)
{
await db.PublishedScheduleOccurrences
.Where(x => x.SchedulePlanId == plan.Id)
.ExecuteDeleteAsync(cancellationToken);
var rows = new List<PublishedScheduleOccurrence>(WriteBatchSize);
foreach (var entry in plan.Entries)
for (var week = entry.StartWeek; week <= entry.EndWeek; week++)
{
if (entry.WeekPattern == WeekPattern.Odd && week % 2 == 0 ||
entry.WeekPattern == WeekPattern.Even && week % 2 != 0) continue;
rows.Add(new PublishedScheduleOccurrence
{
SchedulePlanId = plan.Id,
AcademicTermId = plan.AcademicTermId,
ScheduleEntryId = entry.Id,
TeachingTaskId = entry.TeachingTaskId,
ClassroomId = entry.ClassroomId,
Week = week,
DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod,
PeriodCount = entry.PeriodCount,
Kind = entry.Kind
});
if (rows.Count == WriteBatchSize)
await WriteBatchAsync(rows, cancellationToken);
}
if (rows.Count > 0)
await WriteBatchAsync(rows, cancellationToken);
}
private async Task WriteBatchAsync(
List<PublishedScheduleOccurrence> rows,
CancellationToken cancellationToken)
{
db.PublishedScheduleOccurrences.AddRange(rows);
await db.SaveChangesAsync(cancellationToken);
foreach (var row in rows)
db.Entry(row).State = EntityState.Detached;
rows.Clear();
}
}
+4
View File
@@ -422,6 +422,7 @@ builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<DemoDataSeeder>();
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
builder.Services.AddScoped<TimetableDataService>();
builder.Services.AddScoped<PublishedTimetableProjectionService>();
builder.Services.AddScoped<AutomaticScheduleGenerator>();
builder.Services.AddScoped<PersonalCalendarService>();
builder.Services.AddScoped<ClassroomReservationAvailabilityService>();
@@ -437,6 +438,9 @@ builder.Services.AddScoped<ExamArrangementJobProcessor>();
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
builder.Services.AddScoped<ExamPublishJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>();
@@ -0,0 +1,176 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class CourseGradeStatisticsRefreshSchedulerTests
{
[Fact]
public async Task EnqueueDueAsync_UsesPersistentScheduleAndHonorsInterval()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var scheduler = new CourseGradeStatisticsRefreshScheduler(
db,
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
var now = new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc);
await scheduler.EnqueueDueAsync(now, CancellationToken.None);
var setting = Assert.Single(await db.CourseGradeStatisticsRefreshSettings.ToListAsync());
Assert.True(setting.IsEnabled);
Assert.Equal(now, setting.LastRunAt);
await scheduler.EnqueueDueAsync(now.AddMinutes(1), CancellationToken.None);
Assert.Equal(now, setting.LastRunAt);
}
[Fact]
public async Task EnqueueStaleAsync_GroupsByCourseTerm_AndSkipsActiveTarget()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var college = new College { Code = "SCHEDULE", Name = "定时任务学院" };
var major = new Major
{
Code = "SCHEDULE-M",
Name = "定时任务专业",
College = college,
DegreeType = "本科"
};
var administrativeClass = new AdministrativeClass
{
Code = "SCHEDULE-C",
Name = "定时任务一班",
Major = major,
Grade = 2026
};
var student = new Student
{
StudentNumber = "SCHEDULE-001",
Name = "定时任务学生",
AdministrativeClass = administrativeClass,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1)
};
var course = new Course
{
Code = "SCHEDULE-COURSE",
Name = "定时任务课程",
College = college,
Credits = 2,
TotalHours = 32,
LectureHours = 32
};
var term = new AcademicTerm
{
Code = "2026-A",
Name = "2026-2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var firstTask = new TeachingTask
{
TaskNumber = "SCHEDULE-T1",
Name = "定时任务教学班一",
Course = course,
AcademicTerm = term,
Capacity = 30
};
var secondTask = new TeachingTask
{
TaskNumber = "SCHEDULE-T2",
Name = "定时任务教学班二",
Course = course,
AcademicTerm = term,
Capacity = 30
};
var firstSheet = PublishedSheet(firstTask, student, 80m);
var secondSheet = PublishedSheet(secondTask, student, 90m);
db.GradeSheets.AddRange(firstSheet, secondSheet);
await db.SaveChangesAsync();
var scheduler = new CourseGradeStatisticsRefreshScheduler(
db,
NullLogger<CourseGradeStatisticsRefreshScheduler>.Instance);
var queued = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
var queuedAgain = await scheduler.EnqueueStaleAsync(100, CancellationToken.None);
Assert.Equal(1, queued);
Assert.Equal(0, queuedAgain);
var job = Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
var outbox = Assert.Single(await db.BackgroundJobOutboxMessages.ToListAsync());
Assert.Equal(BackgroundJobKind.CourseGradeStatisticsRefresh, outbox.JobKind);
job.Status = CourseGradeStatisticsRefreshJobStatus.Succeeded;
db.TeachingTaskGradeStatistics.AddRange(
FreshStatistic(firstSheet, course, term),
FreshStatistic(secondSheet, course, term));
await db.SaveChangesAsync();
var queuedAfterFreshStatistics = await scheduler.EnqueueStaleAsync(
100,
CancellationToken.None);
Assert.Equal(0, queuedAfterFreshStatistics);
Assert.Single(await db.CourseGradeStatisticsRefreshJobs.ToListAsync());
}
private static GradeSheet PublishedSheet(
TeachingTask task,
Student student,
decimal score)
{
var sheet = new GradeSheet
{
TeachingTask = task,
Status = GradeSheetStatus.Published,
PublishedAt = DateTime.UtcNow
};
sheet.Records.Add(new GradeRecord
{
GradeSheet = sheet,
Student = student,
TotalScore = score,
GradePoint = 3m
});
return sheet;
}
private static TeachingTaskGradeStatistic FreshStatistic(
GradeSheet sheet,
Course course,
AcademicTerm term) => new()
{
GradeSheetId = sheet.Id,
TeachingTaskId = sheet.TeachingTaskId,
CourseId = course.Id,
AcademicTermId = term.Id,
StudentCount = 1,
PassedCount = 1,
HighestScore = 80m,
AverageScore = 80m,
MedianScore = 80m,
LowestScore = 80m,
PassRate = 100m,
CalculatedAt = DateTime.UtcNow.AddMinutes(1)
};
}
@@ -205,6 +205,79 @@ public sealed class CourseSelectionsControllerTests
x.Grade == 2026));
}
[Fact]
public async Task Student_options_include_an_administrator_assigned_offering_outside_the_students_class_scope()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var data = await SeedFullOfferingAsync(db);
var originalClass = await db.AdministrativeClasses.SingleAsync();
var otherClass = new AdministrativeClass
{
Code = "CS2026-02",
Name = "计科 2026-2 班",
MajorId = originalClass.MajorId,
Grade = 2026
};
var taskId = await db.CourseSelectionOfferings
.Where(x => x.Id == data.OfferingId)
.Select(x => x.TeachingTaskId)
.SingleAsync();
var task = await db.TeachingTasks
.Include(x => x.Classes)
.SingleAsync(x => x.Id == taskId);
task.Classes.Clear();
task.Classes.Add(new TeachingTaskClass { AdministrativeClassId = otherClass.Id });
task.SchedulingMode = TeachingTaskSchedulingMode.Standard;
var termId = await db.CourseSelectionRounds
.Where(x => x.Id == data.RoundId)
.Select(x => x.AcademicTermId)
.SingleAsync();
var plan = new SchedulePlan
{
AcademicTermId = termId,
Name = "正式课表",
Version = "V1",
Status = SchedulePlanStatus.Published,
PublishedAt = DateTime.UtcNow
};
db.AddRange(otherClass, plan);
await db.SaveChangesAsync();
db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = task.Id,
DayOfWeek = 1,
StartPeriod = 1,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
});
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var controller = new CourseSelectionsController(
db,
new StudentDataScope(data.EnrolledUserId));
var result = Assert.IsType<OkObjectResult>(await controller.GetStudentOptions(
data.RoundId,
CancellationToken.None));
var offerings = ReadProperty<IEnumerable<StudentOfferingDto>>(
result.Value, "Offerings");
var assignedOffering = Assert.Single(offerings);
Assert.Equal(data.OfferingId, assignedOffering.Id);
Assert.Equal(CourseEnrollmentStatus.Enrolled, assignedOffering.EnrollmentStatus);
Assert.Single(assignedOffering.Schedules);
}
private static async Task<SeededSelection> SeedFullOfferingAsync(AppDbContext db)
{
var college = new College { Code = "CS", Name = "计算机学院" };
@@ -358,6 +431,14 @@ public sealed class CourseSelectionsControllerTests
return Assert.IsType<int>(property.GetValue(value));
}
private static T ReadProperty<T>(object? value, string propertyName)
{
Assert.NotNull(value);
var property = value.GetType().GetProperty(propertyName);
Assert.NotNull(property);
return Assert.IsAssignableFrom<T>(property.GetValue(value));
}
private static Student CreateStudent(
string number,
string name,
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
@@ -11,6 +12,160 @@ namespace Jiaowu.Api.Tests;
public sealed class ExperimentGradesControllerTests
{
[Fact]
public async Task AssignedTeacher_CanSubmitExperimentGradeSheet()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-TEACHER",
Name = "教师提交实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.ExperimentProjects.Add(project);
await fixture.Db.SaveChangesAsync();
var admin = fixture.ExperimentGrades(fixture.AdminScope);
await admin.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
100)]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.SingleAsync();
var record = Assert.Single(sheet.Records);
var item = Assert.Single(sheet.Items);
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
Assert.IsType<NoContentResult>(await teacher.UpdateRecords(
sheet.Id,
new ExperimentGradeRecordsRequest(
[
new ExperimentGradeRecordRequest(
record.Id,
ExperimentParticipationStatus.Completed,
false,
1,
null,
null,
false,
null,
[new ExperimentGradeItemScoreRequest(item.Id, 85, null)])
]),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
Assert.IsType<NoContentResult>(await teacher.Submit(
sheet.Id,
CancellationToken.None));
}
[Fact]
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var collegeId = await fixture.Db.Courses
.Where(x => x.Id == fixture.Task.CourseId)
.Select(x => x.CollegeId)
.SingleAsync();
for (var index = 1; index <= 12; index++)
{
fixture.Db.ExperimentProjects.Add(new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = $"LAB-{index:00}",
Name = $"分页实验 {index:00}",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
});
}
var otherCollege = new College { Code = "OTHER", Name = "其他学院" };
var otherCourse = new Course
{
CollegeId = otherCollege.Id,
Code = "OTHER-LAB",
Name = "其他学院实验",
Credits = 1,
TotalHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var otherTask = new TeachingTask
{
AcademicTermId = fixture.Term.Id,
CourseId = otherCourse.Id,
TaskNumber = "OTHER-LAB-01",
Name = "其他学院实验班",
Capacity = 20,
Status = TeachingTaskStatus.Published
};
var otherProject = new ExperimentProject
{
TeachingTaskId = otherTask.Id,
Code = "FOREIGN-LAB",
Name = "不可见实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.AddRange(otherCollege, otherCourse, otherTask, otherProject);
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
var collegeScope = new FixedScope(new CurrentUserScope(
Guid.NewGuid(),
"学院管理员",
collegeId,
DataScope.College,
new HashSet<string>([SystemRoles.CollegeAdmin])));
var controller = fixture.ExperimentGrades(collegeScope);
var page = Assert.IsType<OkObjectResult>(await controller.GetManagement(
null,
null,
null,
null,
1,
10,
CancellationToken.None));
Assert.Equal(12, Property<int>(page.Value, "Total"));
Assert.Equal(10, Property<System.Collections.IEnumerable>(
page.Value,
"Items").Cast<object>().Count());
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
null,
null,
null,
"LAB-12",
1,
10,
CancellationToken.None));
Assert.Equal(1, Property<int>(filtered.Value, "Total"));
}
private static T Property<T>(object? value, string name) =>
Assert.IsAssignableFrom<T>(
value!.GetType().GetProperty(name)!.GetValue(value));
[Fact]
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
{
@@ -108,7 +263,17 @@ public sealed class ExperimentGradesControllerTests
var studentResult = await fixture
.ExperimentGrades(fixture.StudentScope)
.GetMine(CancellationToken.None);
Assert.IsType<OkObjectResult>(studentResult);
var studentResultOk = Assert.IsType<OkObjectResult>(studentResult);
var studentCourse = Assert.Single(
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
studentResultOk.Value).Cast<object>());
Assert.Equal(85m, Property<decimal>(
studentCourse,
"ExperimentCourseScore"));
var persistedExperimentCourseGrade = await fixture.Db
.ExperimentCourseGrades.SingleAsync();
Assert.Equal(85m, persistedExperimentCourseGrade.WeightedAverageScore);
Assert.Equal(1, persistedExperimentCourseGrade.PublishedProjectCount);
var courseSheet = new GradeSheet
{
@@ -176,6 +341,83 @@ public sealed class ExperimentGradesControllerTests
Assert.IsType<ConflictObjectResult>(manualOverride);
}
[Fact]
public async Task ExperimentCourseGrade_IsPersistedAsWeightedAverage()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var firstProject = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-W1",
Name = "低权重实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
GradeSheet = new ExperimentGradeSheet
{
ContributionWeight = 1,
Status = ExperimentGradeSheetStatus.Published,
Records =
[
new ExperimentGradeRecord
{
StudentId = fixture.Student.Id,
ParticipationStatus = ExperimentParticipationStatus.Completed,
TotalScore = 80,
IsPassed = true
}
]
}
};
var secondProject = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-W2",
Name = "高权重实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
GradeSheet = new ExperimentGradeSheet
{
ContributionWeight = 3,
Status = ExperimentGradeSheetStatus.Published,
Records =
[
new ExperimentGradeRecord
{
StudentId = fixture.Student.Id,
ParticipationStatus = ExperimentParticipationStatus.Completed,
TotalScore = 90,
IsPassed = true
}
]
}
};
fixture.Db.AddRange(firstProject, secondProject);
await fixture.Db.SaveChangesAsync();
await ExperimentGradeAggregationService.RefreshTeachingTaskAsync(
fixture.Db,
fixture.Task.Id,
CancellationToken.None);
var persisted = await fixture.Db.ExperimentCourseGrades.SingleAsync();
Assert.Equal(87.5m, persisted.WeightedAverageScore);
Assert.Equal(4m, persisted.TotalWeight);
Assert.Equal(2, persisted.PublishedProjectCount);
firstProject.GradeSheet!.Records.Single().TotalScore = 0;
await fixture.Db.SaveChangesAsync();
var referenced = await ExperimentGradeAggregationService.CalculateAsync(
fixture.Db,
fixture.Task.Id,
[fixture.Student.Id],
CancellationToken.None);
Assert.Equal(87.5m, referenced.Scores[fixture.Student.Id]);
}
[Fact]
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
{
@@ -260,6 +502,7 @@ public sealed class ExperimentGradesControllerTests
Student student,
Classroom classroom,
ICurrentUserDataScope adminScope,
ICurrentUserDataScope teacherScope,
ICurrentUserDataScope studentScope)
{
Connection = connection;
@@ -269,6 +512,7 @@ public sealed class ExperimentGradesControllerTests
Student = student;
Classroom = classroom;
AdminScope = adminScope;
TeacherScope = teacherScope;
StudentScope = studentScope;
}
@@ -279,6 +523,7 @@ public sealed class ExperimentGradesControllerTests
public Student Student { get; }
public Classroom Classroom { get; }
public ICurrentUserDataScope AdminScope { get; }
public ICurrentUserDataScope TeacherScope { get; }
public ICurrentUserDataScope StudentScope { get; }
public static async Task<ExperimentGradeFixture> CreateAsync()
@@ -410,6 +655,7 @@ public sealed class ExperimentGradesControllerTests
student,
classroom,
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
Scope(teacherUser, SystemRoles.Teacher, DataScope.Self),
Scope(studentUser, SystemRoles.Student, DataScope.Self));
}
@@ -37,6 +37,106 @@ public sealed class ExperimentsControllerTests
});
}
[Fact]
public async Task BatchProjects_AssignsPastedNamesAndSequentialNamesInGenerationOrder()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
var pasted = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with
{
Names = ["实验一", "实验二"]
};
Assert.IsType<CreatedResult>(await controller.CreateProjects(pasted, CancellationToken.None));
Assert.Equal(["实验一", "实验二"], await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTask!.TaskNumber)
.Select(x => x.Name)
.ToListAsync());
fixture.Db.ExperimentProjects.RemoveRange(fixture.Db.ExperimentProjects);
await fixture.Db.SaveChangesAsync();
var sequential = fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled) with
{
Name = "数据库实验",
GenerateSequentialNames = true
};
Assert.IsType<CreatedResult>(await controller.CreateProjects(sequential, CancellationToken.None));
Assert.Equal(["数据库实验 01", "数据库实验 02"], await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTask!.TaskNumber)
.Select(x => x.Name)
.ToListAsync());
}
[Fact]
public async Task BatchProjectNames_UpdatesOnlySelectedDraftProjectsInRequestOrder()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProjects(
fixture.BatchProjectRequest(ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var ids = await fixture.Db.ExperimentProjects
.OrderByDescending(x => x.TeachingTask!.TaskNumber)
.Select(x => x.Id)
.ToListAsync();
var result = await controller.UpdateProjectNames(
new ExperimentProjectBatchNamesRequest(ids, ["第二个", "第一个"]),
CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var projectsById = await fixture.Db.ExperimentProjects.ToDictionaryAsync(x => x.Id, x => x.Name);
Assert.Equal("第二个", projectsById[ids[0]]);
Assert.Equal("第一个", projectsById[ids[1]]);
}
[Fact]
public async Task CentralizedBatchProjects_CreatesOneProjectForEveryScheduledExperimentLesson()
{
await using var fixture = await ExperimentFixture.CreateAsync();
fixture.Db.ScheduleEntries.Add(new ScheduleEntry
{
SchedulePlanId = fixture.ScheduleEntry.SchedulePlanId,
TeachingTaskId = fixture.Task.Id,
Kind = ScheduleEntryKind.Experiment,
ClassroomId = fixture.SecondClassroom.Id,
DayOfWeek = 3,
StartPeriod = 5,
PeriodCount = 2,
StartWeek = 1,
EndWeek = 8,
WeekPattern = WeekPattern.All
});
await fixture.Db.SaveChangesAsync();
var result = await fixture.Controller(fixture.ManagerScope).CreateProjects(
new ExperimentProjectBatchRequest(
[fixture.Task.Id],
"LAB-ALL",
"全部课表实验",
ExperimentArrangementMode.Centralized,
null,
null,
fixture.Term.StartDate,
fixture.Term.StartDate.AddDays(14)),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
var projects = await fixture.Db.ExperimentProjects
.Where(x => x.Code == "LAB-ALL")
.OrderBy(x => x.ScheduleEntryId)
.ToListAsync();
Assert.Equal(16, projects.Count);
Assert.All(projects, project =>
{
Assert.Equal(fixture.Task.Id, project.TeachingTaskId);
Assert.Equal(ExperimentArrangementMode.Centralized, project.ArrangementMode);
Assert.NotNull(project.ScheduleEntryId);
});
Assert.Equal(2, projects.Select(x => x.ScheduleEntryId).Distinct().Count());
Assert.Equal(8, projects.Select(x => x.ScheduleWeek).Distinct().Count());
}
[Fact]
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
{
@@ -109,6 +109,34 @@ public sealed class MySqlMigrationTests
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void MySql_experiment_course_grade_lookup_uses_provider_safe_predicates()
{
using var db = new AppDbContext(CreateMySqlOptions());
var teachingTaskId = Guid.Parse(
"33333333-3333-3333-3333-333333333333");
var studentIds = new[]
{
Guid.Parse("11111111-1111-1111-1111-111111111111"),
Guid.Parse("22222222-2222-2222-2222-222222222222")
};
var sql = db.ExperimentCourseGrades
.Where(x => x.TeachingTaskId == teachingTaskId)
.WhereIn(studentIds, x => x.StudentId)
.ToQueryString();
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains(
teachingTaskId.ToString(),
sql,
StringComparison.OrdinalIgnoreCase);
Assert.All(studentIds, studentId => Assert.Contains(
studentId.ToString(),
sql,
StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void MySql_attendance_device_hash_query_uses_provider_safe_predicates()
{
@@ -48,8 +48,9 @@ public sealed class OfficialDocumentTests
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code");
Assert.True(result.Content.Length > 5_000);
Assert.True(result.Content.Length > 500, "生成的 PDF 不应为空。");
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
Assert.Equal(
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
result.Sha256);
@@ -85,13 +85,19 @@ public sealed class ScheduleSettingsControllerTests
[classroom.Id],
false,
null,
null),
null,
UpdateExperimentClassroomScope: true,
AllowedExperimentVenueNatures: TeachingVenueNature.Laboratory,
AllowedExperimentClassroomIds: [classroom.Id],
ExperimentRequiredCampusId: campus.Id,
ExperimentRequiredBuildingId: building.Id),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
db.ChangeTracker.Clear();
var constraints = await db.TeachingTaskScheduleConstraints
.Include(item => item.AllowedClassrooms)
.Include(item => item.AllowedExperimentClassrooms)
.OrderBy(item => item.TeachingTaskId)
.ToListAsync();
Assert.Equal(2, constraints.Count);
@@ -103,6 +109,11 @@ public sealed class ScheduleSettingsControllerTests
Assert.Equal(
classroom.Id,
Assert.Single(constraint.AllowedClassrooms).ClassroomId);
Assert.Equal(campus.Id, constraint.ExperimentRequiredCampusId);
Assert.Equal(building.Id, constraint.ExperimentRequiredBuildingId);
Assert.Equal(
classroom.Id,
Assert.Single(constraint.AllowedExperimentClassrooms).ClassroomId);
});
}
@@ -227,6 +227,7 @@ public sealed class TeachingWorkflowRosterTests
Assert.IsType<CreatedResult>(attendanceResult);
Assert.Equal(1, await db.AttendanceRecords.CountAsync());
db.ChangeTracker.Clear();
var grades = new GradesController(db, scope);
var gradeResult = await grades.CreateSheet(
new GradeSheetRequest(task.Id, 40, 60, []),
@@ -234,6 +235,51 @@ public sealed class TeachingWorkflowRosterTests
Assert.IsType<CreatedResult>(gradeResult);
Assert.Equal(1, await db.GradeRecords.CountAsync());
var makeupPlan = new MakeupExamPlan
{
AcademicTermId = term.Id,
Name = "补考成绩录入测试",
Status = MakeupExamPlanStatus.Published,
PublishedAt = DateTime.UtcNow
};
var makeupSession = new MakeupExamSession
{
MakeupExamPlanId = makeupPlan.Id,
TeachingTaskId = task.Id,
ExamDate = new DateOnly(2027, 2, 20),
StartPeriod = 1,
PeriodCount = 2,
StartsAt = new DateTime(2027, 2, 20, 8, 0, 0, DateTimeKind.Utc),
EndsAt = new DateTime(2027, 2, 20, 9, 50, 0, DateTimeKind.Utc),
Enrollments =
[
new MakeupExamEnrollment
{
StudentId = student.Id,
Reason = MakeupReason.Failed
}
]
};
makeupPlan.Sessions.Add(makeupSession);
db.MakeupExamPlans.Add(makeupPlan);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var makeup = new MakeupExamsController(
db,
scope,
new MakeupExamEligibilityService(db));
Assert.IsType<NoContentResult>(await makeup.RecordScores(
makeupSession.Id,
[new RecordMakeupScoreRequest(student.Id, 75)],
CancellationToken.None));
Assert.Equal(
75,
await db.MakeupExamEnrollments
.Where(x => x.MakeupExamSessionId == makeupSession.Id)
.Select(x => x.MakeupScore)
.SingleAsync());
var selections = new CourseSelectionsController(db, scope);
Assert.Single(ReadItems(await selections.GetMyTeachingTasks(
term.Id,
@@ -0,0 +1,78 @@
using System.Collections;
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 WarningRulePersistenceTests
{
[Fact]
public async Task SaveRules_PersistsAutoCheck_AndReturnsNumericType()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "WARN-TERM",
Name = "预警测试学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
db.AcademicTerms.Add(term);
await db.SaveChangesAsync();
var controller = new WarningsController(db, new AllDataScope());
var saveResult = await controller.SaveRules(term.Id,
[
new WarningRuleDto(
WarningType.FailedCredits,
"不及格学分",
2m,
true,
true,
true,
null,
true,
1,
9,
30)
], CancellationToken.None);
Assert.IsType<NoContentResult>(saveResult);
db.ChangeTracker.Clear();
var persisted = Assert.Single(await db.WarningRules.AsNoTracking().ToListAsync());
Assert.True(persisted.AutoCheckEnabled);
Assert.Equal(1, persisted.CheckDayOfWeek);
Assert.Equal(9, persisted.CheckHour);
Assert.Equal(30, persisted.CheckMinute);
var getResult = await controller.GetRules(term.Id, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(getResult);
var row = Assert.Single(Assert.IsAssignableFrom<IEnumerable>(ok.Value).Cast<object>());
var type = row.GetType().GetProperty("Type")?.GetValue(row);
Assert.Equal(1, Assert.IsType<int>(type));
}
private sealed class AllDataScope : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
null,
DataScope.All,
new HashSet<string>([SystemRoles.SuperAdmin]));
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>
<JiaowuBackendVersion>2.3.2-beta.2</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.3.2-beta.2</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.3.2-beta.2</JiaowuSwaggerVersion>
<JiaowuBackendVersion>2.4.0-rc1</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.4.0-rc1</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.4.0-rc1</JiaowuSwaggerVersion>
</PropertyGroup>
</Project>
+1
View File
@@ -36,6 +36,7 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+39 -27
View File
@@ -151,21 +151,14 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
),
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
{
path: '/class-timetable',
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
},
{
path: '/classroom-reservations',
label: hasAnyRole(['CollegeAdmin']) ? '教室借用审核' : '教室借用',
},
...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }),
...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }),
{
path: '/evaluations',
label: isStudent.value ? '学生评教' : isTeacher.value ? '评教结果' : '教学评价',
},
...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }),
...whenVisible(!isStudent.value, {
path: '/class-timetable',
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
}),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{
@@ -173,6 +166,26 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
label: isTeachingAdmin.value ? '选课管理' : '学生选课',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
{
path: '/course-adjustments',
label: isTeacher.value ? '我的调停课' : '调停课审核',
},
),
],
},
{
key: 'teaching-assessment',
label: '教学考核',
items: [
...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }),
...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }),
{
path: '/evaluations',
label: isStudent.value ? '学生评教' : isTeacher.value ? '评教结果' : '教学评价',
},
...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
@@ -181,6 +194,12 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
),
],
},
{
key: 'examinations',
label: '考试管理',
items: [
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']),
{ path: '/other-exams', label: '其他考试成绩' },
@@ -199,26 +218,11 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
label: isStudent.value ? '我的补考' : isTeacher.value ? '补考监考' : '补考安排',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
{
path: '/course-adjustments',
label: isTeacher.value ? '我的调停课' : '调停课审核',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']),
{ path: '/approvals', label: '审批中心' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Counselor', 'Student']),
{ path: '/warnings', label: '学业预警' },
),
],
},
{
key: 'student-status',
label: '学籍管理',
label: '学籍与服务',
items: [
...whenVisible(
isStudent.value,
@@ -232,6 +236,14 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{ path: '/official-documents', label: isStudent.value ? '电子成绩单与证明' : '官方凭证签发' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']),
{ path: '/approvals', label: '审批中心' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Counselor', 'Student']),
{ path: '/warnings', label: '学业预警' },
),
],
},
{
+67
View File
@@ -343,6 +343,30 @@ button { cursor: pointer; }
.form-grid.compact { align-items: center; }
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* 由遮罩层 flex 居中;MessageBox 默认的全屏 fixed 包装层会脱离该布局。 */
.el-overlay.is-message-box {
display: flex !important;
align-items: center;
justify-content: center;
padding: 16px;
}
.el-overlay.is-message-box .el-overlay-message-box {
position: static !important;
display: block !important;
width: min(420px, calc(100vw - 32px));
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.el-overlay.is-message-box .el-overlay-message-box::after { display: none !important; }
.el-overlay.is-message-box .el-message-box {
width: 100%;
margin: 0 !important;
color: var(--ink);
background: #fff !important;
box-shadow: 0 18px 48px rgba(18, 37, 63, .28);
}
.password-reset-form { margin-top: 18px; }
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
@@ -431,6 +455,10 @@ button { cursor: pointer; }
.module-toolbar b, .module-toolbar span { display: block; }
.module-toolbar b { font-size: 15px; }
.module-toolbar span { margin-top: 4px; color: var(--muted); font-size: 10px; }
.curriculum-view-actions { display: flex; align-items: center; gap: 10px; }
.curriculum-view-actions .el-button + .el-button { margin-left: 0; }
.page-intro-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.page-intro-actions .el-button + .el-button { margin-left: 0; }
.curriculum-module { margin-top: 12px; border: 1px solid var(--line); }
.curriculum-module > header { min-height: 72px; padding: 13px 16px; display: flex; align-items: center; gap: 18px; background: #f8fafb; border-bottom: 1px solid var(--line); }
.curriculum-module > header > div:first-child { min-width: 160px; }
@@ -439,6 +467,34 @@ button { cursor: pointer; }
.curriculum-module > header p { margin: 0 auto 0 0; color: var(--muted); font-size: 11px; }
.curriculum-module > header > div:last-child { display: flex; align-items: center; white-space: nowrap; }
.curriculum-course-table { min-height: 80px; }
.semester-view { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.semester-card { min-width: 0; border: 1px solid var(--line); background: #fff; }
.semester-card > header { min-height: 70px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; background: linear-gradient(135deg, #f4faf9, #f8fafb); border-bottom: 1px solid var(--line); }
.semester-card > header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .12em; }
.semester-card h4 { margin: 7px 0 0; font-size: 15px; }
.semester-card > header > b { color: #1d6b68; font: 700 18px/1 Consolas, monospace; }
.semester-summary { display: flex; gap: 14px; padding: 9px 16px; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); }
.semester-summary span + span { padding-left: 14px; border-left: 1px solid var(--line); }
.semester-courses { margin: 0; padding: 0; list-style: none; }
.semester-courses li { min-height: 68px; padding: 11px 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #edf0f2; }
.semester-courses li:last-child { border-bottom: none; }
.semester-courses li > div { min-width: 0; display: grid; gap: 3px; }
.semester-courses li span { color: var(--teal); font-size: 10px; }
.semester-courses li b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.semester-courses li small { color: var(--muted); font-size: 10px; }
.semester-courses li > strong { flex: none; font: 700 15px/1 Consolas, monospace; }
.semester-courses li > strong small { margin-left: 3px; font: 400 9px/1 inherit; }
.semester-empty { margin: 0; padding: 25px 16px; color: var(--muted); font-size: 11px; text-align: center; }
.course-group-manager { min-height: 420px; display: grid; grid-template-columns: 230px minmax(0, 1fr); border: 1px solid var(--line); }
.course-group-manager > aside { padding: 12px; display: grid; align-content: start; gap: 6px; border-right: 1px solid var(--line); background: #fafbfc; }
.course-group-manager > aside > button:not(.el-button) { padding: 11px; display: grid; gap: 4px; text-align: left; border: 1px solid transparent; background: transparent; }
.course-group-manager > aside > button.active { border-color: #b8d9d5; background: #edf8f6; }
.course-group-manager > aside span { color: var(--teal); font: 700 9px/1 Consolas, monospace; }
.course-group-manager > aside b { font-size: 13px; }
.course-group-manager > aside small { color: var(--muted); font-size: 10px; }
.course-group-manager > main { min-width: 0; padding: 16px; }
.course-group-actions { margin: 0 0 18px; }
.course-group-add { margin: 18px 0 10px; display: grid; grid-template-columns: auto minmax(200px, 1fr) auto; align-items: center; gap: 10px; }
.task-summary { min-height: 82px; padding: 15px 22px; display: flex; align-items: center; gap: 28px; color: white; background: linear-gradient(108deg, #17295a, #263f80); }
.task-summary > div { min-width: 170px; display: flex; align-items: baseline; gap: 8px; }
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
@@ -512,6 +568,8 @@ button { cursor: pointer; }
.constraint-batch-form { margin-top: 16px; display: grid; gap: 10px; }
.constraint-batch-form > .el-checkbox { padding: 8px 10px; background: #f7f9fb; border-left: 3px solid #ccd8e1; }
.batch-classroom-scope { padding: 12px 14px 2px; display: grid; gap: 12px; border: 1px solid #d8e2e8; background: #fbfcfd; }
.experiment-classroom-scope { margin-bottom: 18px; padding: 12px 14px 2px; border: 1px solid #d8e2e8; background: #fbfcfd; }
.experiment-classroom-scope__title { margin-bottom: 12px; color: #34435e; font-size: 13px; font-weight: 650; }
.constraint-list article { min-width: 0; padding: 13px 15px; display: grid; grid-template-columns: minmax(230px, 1fr) minmax(170px, auto) auto; align-items: center; gap: 14px; border: 1px solid var(--line); background: #fff; }
.constraint-list article > div:first-child { min-width: 0; display: grid; gap: 4px; }
.constraint-list article span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
@@ -1216,6 +1274,9 @@ button { cursor: pointer; }
.sso-binding-card > a { color: #315b73; font-size: 13px; font-weight: 650; text-decoration: none; }
@media (max-width: 1100px) {
.desktop-navigation { padding-inline: 20px; }
.horizontal-menu.el-menu--horizontal > .el-menu-item,
.horizontal-menu.el-menu--horizontal > .el-sub-menu .el-sub-menu__title { padding-inline: 12px; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.dashboard-grid { grid-template-columns: 1fr; }
.login-page { grid-template-columns: 1fr 430px; }
@@ -1278,6 +1339,12 @@ button { cursor: pointer; }
.plan-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.curriculum-module > header { align-items: flex-start; flex-wrap: wrap; }
.curriculum-module > header p { order: 3; width: 100%; }
.module-toolbar { align-items: flex-start; flex-direction: column; gap: 12px; }
.curriculum-view-actions { width: 100%; justify-content: space-between; }
.semester-view { grid-template-columns: 1fr; }
.course-group-manager { grid-template-columns: 1fr; }
.course-group-manager > aside { max-height: 220px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
.course-group-add { grid-template-columns: 1fr; align-items: stretch; }
.task-summary { align-items: flex-start; flex-direction: column; gap: 12px; }
.task-summary > div { width: 100%; }
.task-summary p { padding: 12px 0 0; border-left: none; border-top: 1px solid rgba(255,255,255,.16); line-height: 1.6; }
@@ -24,6 +24,14 @@ const rows = computed(() => [
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
const recommendation = computed(() => {
const value = selectedRow.value?.value
if (!value) return ''
if (Number(value.passRate) < 70) return `合格率为 ${score(value.passRate)}%,建议优先复核低分段学生的平时与期末分项,并安排针对性答疑。`
if (Number(value.averageScore) < 70) return `平均分为 ${score(value.averageScore)},建议检查易失分知识点与教学进度,结合分数段安排补强。`
if (Number(value.standardDeviation) > 20) return `成绩离散度较高,建议关注不同教学班或学生群体的学习差异,核对评价标准与教学支持。`
return `平均分 ${score(value.averageScore)}、合格率 ${score(value.passRate)}%,当前表现稳定;可重点关注低分段学生的持续跟进。`
})
function score(value: unknown) {
return Number(value).toFixed(1)
@@ -210,6 +218,7 @@ onBeforeUnmount(() => {
</dl>
</article>
</section>
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
<section v-if="rows.length" class="chart-panel">
+237 -8
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
import { Collection, CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
@@ -14,15 +14,20 @@ const colleges = ref<any[]>([])
const majors = ref<any[]>([])
const grades = ref<number[]>([])
const courses = ref<any[]>([])
const courseGroups = ref<any[]>([])
const planDialog = ref(false)
const moduleDialog = ref(false)
const courseDialog = ref(false)
const cloneDialog = ref(false)
const courseGroupDialog = ref(false)
const courseGroupImportDialog = ref(false)
const detailView = ref<'structure' | 'semester'>('structure')
const editingPlanId = ref('')
const editingPlanStatus = ref('')
const editingModuleId = ref('')
const editingCourseId = ref('')
const activeModuleId = ref('')
const activeCourseGroupId = ref('')
const query = reactive({
page: 1,
pageSize: 20,
@@ -36,6 +41,9 @@ const planForm = reactive<Record<string, any>>({})
const moduleForm = reactive<Record<string, any>>({})
const courseForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const courseGroupForm = reactive<Record<string, any>>({})
const courseGroupCourseForm = reactive<Record<string, any>>({})
const courseGroupImportForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
@@ -47,6 +55,9 @@ const courseTypeLabels: Record<string, string> = {
Elective: '组内选修',
}
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
const canManageCourseGroups = computed(() =>
auth.user?.roles.some((role) => role === 'SuperAdmin' || role === 'AcademicAdmin') ?? false,
)
const availableMajors = computed(() => majors.value)
const filteredMajors = computed(() =>
query.collegeId
@@ -68,12 +79,46 @@ const isDraft = computed(() => selected.value?.status === 'Draft')
const isPublished = computed(() => selected.value?.status === 'Published')
const canEdit = computed(() => isDraft.value || isPublished.value)
const isEditingPublished = computed(() => editingPlanStatus.value === 'Published')
const activeCourseGroup = computed(() =>
courseGroups.value.find((group) => group.id === activeCourseGroupId.value) ?? null,
)
const importingCourseGroup = computed(() =>
courseGroups.value.find((group) => group.id === courseGroupImportForm.courseGroupId) ?? null,
)
const configuredCredits = computed(() =>
selected.value?.modules.reduce(
(sum: number, module: any) => sum + Number(module.requiredCredits),
0,
) ?? 0,
)
const semesterGroups = computed(() => {
if (!selected.value) return []
const courses = selected.value.modules.flatMap((module: any) =>
module.courses.map((course: any) => ({
...course,
moduleCode: module.code,
moduleName: module.name,
})),
)
const semesterCount = Number(selected.value.schoolingYears) * 2
return Array.from({ length: semesterCount }, (_, index) => {
const semester = index + 1
const items = courses
.filter((course: any) => Number(course.recommendedSemester) === semester)
.sort((first: any, second: any) =>
first.moduleCode.localeCompare(second.moduleCode) ||
first.courseCode.localeCompare(second.courseCode),
)
return {
semester,
courses: items,
credits: items.reduce((sum: number, course: any) => sum + Number(course.credits), 0),
totalHours: items.reduce((sum: number, course: any) => sum + Number(course.totalHours), 0),
}
})
})
async function loadPlans(keepSelection = true) {
loading.value = true
@@ -112,6 +157,97 @@ async function loadDetail(id: string) {
}
}
async function loadCourseGroups() {
try {
courseGroups.value = (await http.get('/course-groups')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openCourseGroups() {
courseGroupDialog.value = true
openCourseGroup(activeCourseGroupId.value || undefined)
}
function openCourseGroup(id?: string) {
activeCourseGroupId.value = id ?? ''
const group = courseGroups.value.find((item) => item.id === id)
Object.assign(courseGroupForm, {
code: group?.code ?? '', name: group?.name ?? '', description: group?.description ?? '',
})
courseGroupCourseForm.courseId = undefined
}
async function saveCourseGroup() {
if (!courseGroupForm.code?.trim() || !courseGroupForm.name?.trim()) {
ElMessage.warning('请填写课程组编码和名称。')
return
}
try {
if (activeCourseGroupId.value) {
await http.put(`/course-groups/${activeCourseGroupId.value}`, courseGroupForm)
} else {
const { data } = await http.post('/course-groups', courseGroupForm)
activeCourseGroupId.value = data.id
}
await loadCourseGroups()
openCourseGroup(activeCourseGroupId.value)
ElMessage.success('课程组已保存')
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function deleteCourseGroup() {
if (!activeCourseGroup.value) return
try {
await ElMessageBox.confirm(`确定删除课程组“${activeCourseGroup.value.name}”吗?`, '删除课程组', { type: 'warning' })
await http.delete(`/course-groups/${activeCourseGroupId.value}`)
await loadCourseGroups()
openCourseGroup()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function addCourseToGroup() {
if (!activeCourseGroupId.value || !courseGroupCourseForm.courseId) return
try {
await http.post(`/course-groups/${activeCourseGroupId.value}/courses`, courseGroupCourseForm)
await loadCourseGroups()
openCourseGroup(activeCourseGroupId.value)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function removeCourseFromGroup(courseId: string) {
try {
await http.delete(`/course-groups/${activeCourseGroupId.value}/courses/${courseId}`)
await loadCourseGroups()
openCourseGroup(activeCourseGroupId.value)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function openCourseGroupImport(moduleId: string) {
activeModuleId.value = moduleId
Object.assign(courseGroupImportForm, {
courseGroupId: courseGroups.value[0]?.id, recommendedSemester: 1, type: 'Elective', notes: '',
})
courseGroupImportDialog.value = true
}
async function importCourseGroup() {
if (!courseGroupImportForm.courseGroupId) {
ElMessage.warning('请选择课程组。')
return
}
if (!await confirmPublishedChange('课程组导入')) return
try {
await http.post(`/curriculum-plans/${selected.value.id}/modules/${activeModuleId.value}/course-groups/${courseGroupImportForm.courseGroupId}`, courseGroupImportForm)
courseGroupImportDialog.value = false
await loadDetail(selected.value.id)
ElMessage.success('课程组已导入,可按本方案需要继续逐门调整。')
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function resetFilters() {
Object.assign(query, {
page: 1,
@@ -327,6 +463,7 @@ onMounted(async () => {
const [optionRes, courseRes] = await Promise.all([
http.get('/curriculum-plans/filter-options'),
http.get('/courses/options'),
loadCourseGroups(),
])
colleges.value = optionRes.data.colleges
majors.value = optionRes.data.majors
@@ -347,7 +484,10 @@ onMounted(async () => {
<h2>培养方案</h2>
<p>由学院按专业和入学年级维护课程结构已发布方案可受控调整修改结果即时生效</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
<div class="page-intro-actions">
<el-button v-if="canManageCourseGroups" :icon="Collection" @click="openCourseGroups">课程组</el-button>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
</div>
</section>
<section class="maintenance-scope" aria-label="培养方案维护范围">
@@ -426,7 +566,7 @@ onMounted(async () => {
</div>
<div class="plan-actions">
<el-button v-if="canEdit" @click="openPlan(selected)">编辑</el-button>
<el-button :icon="CopyDocument" @click="openClone">复制版本</el-button>
<el-button :icon="CopyDocument" @click="openClone">修订并创建草稿</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除</el-button>
</div>
@@ -450,12 +590,21 @@ onMounted(async () => {
<div class="module-toolbar">
<div>
<b>课程结构</b>
<span>指定必修须逐门通过英语体育等多选课程用组内选修修满模块最低学分即可</span>
<b>{{ detailView === 'structure' ? '课程结构' : '学期视图' }}</b>
<span>{{ detailView === 'structure'
? '指定必修须逐门通过;英语、体育等多选课程用“组内选修”,修满模块最低学分即可'
: '按建议修读学期展示课程安排,包含每学期的课程数量、学分和学时。' }}</span>
</div>
<div class="curriculum-view-actions">
<el-radio-group v-model="detailView" size="small" aria-label="培养方案展示方式">
<el-radio-button value="structure">课程结构</el-radio-button>
<el-radio-button value="semester">学期视图</el-radio-button>
</el-radio-group>
<el-button v-if="canEdit && detailView === 'structure'" :icon="Plus" @click="openModule()">新增模块</el-button>
</div>
<el-button v-if="canEdit" :icon="Plus" @click="openModule()">新增模块</el-button>
</div>
<template v-if="detailView === 'structure'">
<section v-for="module in selected.modules" :key="module.id" class="curriculum-module">
<header>
<div>
@@ -471,6 +620,7 @@ onMounted(async () => {
<div v-if="canEdit">
<el-button link type="primary" @click="openModule(module)">编辑</el-button>
<el-button link type="danger" @click="deleteModule(module)">删除</el-button>
<el-button size="small" @click="openCourseGroupImport(module.id)">从课程组添加</el-button>
<el-button size="small" :icon="Plus" @click="openCourse(module.id)">添加课程</el-button>
</div>
</header>
@@ -500,6 +650,34 @@ onMounted(async () => {
</el-table>
</section>
<el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" />
</template>
<section v-else class="semester-view" aria-label="课程学期安排">
<article v-for="group in semesterGroups" :key="group.semester" class="semester-card">
<header>
<div>
<span>SEMESTER {{ String(group.semester).padStart(2, '0') }}</span>
<h4> {{ group.semester }} 学期</h4>
</div>
<b>{{ group.courses.length }} </b>
</header>
<div class="semester-summary">
<span>{{ group.credits }} 学分</span>
<span>{{ group.totalHours }} 学时</span>
</div>
<ul v-if="group.courses.length" class="semester-courses">
<li v-for="course in group.courses" :key="course.id">
<div>
<span>{{ course.moduleName }}</span>
<b>{{ course.courseName }}</b>
<small>{{ course.courseCode }} · {{ courseTypeLabels[course.type] }}</small>
</div>
<strong>{{ course.credits }}<small>学分</small></strong>
</li>
</ul>
<p v-else class="semester-empty">本学期暂未安排课程</p>
</article>
</section>
</template>
</main>
</div>
@@ -527,7 +705,8 @@ onMounted(async () => {
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template>
</el-dialog>
<el-dialog v-model="cloneDialog" title="复制为新版本" width="520px">
<el-dialog v-model="cloneDialog" title="修订培养方案并创建草稿" width="520px">
<p class="form-help">将完整复制当前方案为独立草稿修订后的课程模块和课程组导入内容均可单独调整不影响原版本</p>
<el-form label-position="top">
<el-form-item label="方案名称" required><el-input v-model="cloneForm.name" /></el-form-item>
<div class="form-grid">
@@ -535,7 +714,7 @@ onMounted(async () => {
<el-form-item label="适用入学年级" required><el-input-number v-model="cloneForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item>
</div>
</el-form>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">复制</el-button></template>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">创建修订草稿</el-button></template>
</el-dialog>
<el-dialog v-model="moduleDialog" :title="editingModuleId ? '编辑课程模块' : '新增课程模块'" width="520px">
@@ -572,5 +751,55 @@ onMounted(async () => {
</el-form>
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
</el-dialog>
<el-dialog v-model="courseGroupImportDialog" title="从课程组添加课程" width="560px">
<el-alert type="info" :closable="false" show-icon title="导入后课程会复制到当前方案,可逐门修改建议学期、修读规则和备注,不影响公共课程组。" />
<el-form label-position="top">
<el-form-item label="课程组" required>
<el-select v-model="courseGroupImportForm.courseGroupId" filterable>
<el-option v-for="group in courseGroups" :key="group.id" :label="`${group.code} · ${group.name}${group.courseCount} 门)`" :value="group.id" />
</el-select>
</el-form-item>
<p v-if="importingCourseGroup" class="form-help">将导入{{ importingCourseGroup.courses.map((course: any) => course.courseName).join('、') }}</p>
<div class="form-grid">
<el-form-item label="修读规则"><el-select v-model="courseGroupImportForm.type"><el-option v-for="(label, value) in courseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="建议学期"><el-input-number v-model="courseGroupImportForm.recommendedSemester" :min="1" :max="selected?.schoolingYears * 2" /></el-form-item>
</div>
<el-form-item label="统一备注"><el-input v-model="courseGroupImportForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="courseGroupImportDialog = false">取消</el-button><el-button type="primary" @click="importCourseGroup">导入课程组</el-button></template>
</el-dialog>
<el-dialog v-model="courseGroupDialog" title="公共课程组" width="920px">
<div class="course-group-manager">
<aside>
<el-button type="primary" :icon="Plus" @click="openCourseGroup()">新建课程组</el-button>
<button v-for="group in courseGroups" :key="group.id" type="button" :class="{ active: activeCourseGroupId === group.id }" @click="openCourseGroup(group.id)">
<span>{{ group.code }}</span><b>{{ group.name }}</b><small>{{ group.courseCount }} 门课程</small>
</button>
</aside>
<main>
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="课程组编码" required><el-input v-model="courseGroupForm.code" /></el-form-item>
<el-form-item label="课程组名称" required><el-input v-model="courseGroupForm.name" /></el-form-item>
</div>
<el-form-item label="说明"><el-input v-model="courseGroupForm.description" type="textarea" :rows="2" /></el-form-item>
</el-form>
<div class="course-group-actions"><el-button type="primary" @click="saveCourseGroup">保存课程组</el-button><el-button v-if="activeCourseGroup" type="danger" plain @click="deleteCourseGroup">删除</el-button></div>
<div v-if="activeCourseGroup" class="course-group-add">
<b>组内课程</b>
<el-select v-model="courseGroupCourseForm.courseId" filterable placeholder="选择课程加入课程组"><el-option v-for="course in courses" :key="course.id" :label="`${course.code} · ${course.name}`" :value="course.id" /></el-select>
<el-button type="primary" @click="addCourseToGroup">加入</el-button>
</div>
<el-table v-if="activeCourseGroup" :data="activeCourseGroup.courses">
<el-table-column prop="courseCode" label="课程编码" width="120" />
<el-table-column prop="courseName" label="课程名称" />
<el-table-column prop="credits" label="学分" width="80" />
<el-table-column label="操作" width="80"><template #default="{ row }"><el-button link type="danger" @click="removeCourseFromGroup(row.courseId)">移除</el-button></template></el-table-column>
</el-table>
</main>
</div>
</el-dialog>
</div>
</template>
+195 -4
View File
@@ -21,6 +21,7 @@ import {
import http from '../api/http'
import { useAuthStore } from '../stores/auth'
import StudentDashboardView from './StudentDashboardView.vue'
import TeacherDashboardView from './TeacherDashboardView.vue'
interface DashboardData {
audience: {
@@ -57,9 +58,24 @@ interface DashboardData {
classroomReservations: number
generalApprovals: number
}
greeting: DashboardGreeting
generatedAt: string
}
interface DashboardGreeting {
role: string
title: string
subtitle: string
label: string
narrative: string
insights: Array<{
label: string
value: string
hint: string
tone: 'calm' | 'positive' | 'attention'
}>
}
interface DashboardLink {
key: string
label: string
@@ -68,24 +84,46 @@ interface DashboardLink {
icon: Component
}
interface WarningRecord {
id: string
studentName: string
studentNumber: string
className: string
status: number
detail: string
}
const router = useRouter()
const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? [])
const isStudentOverview = computed(() =>
roles.value.includes('Student') &&
!roles.value.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role),
),
)
const isTeacherOverview = computed(() =>
roles.value.includes('Teacher') &&
!roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role)),
)
const loading = ref(true)
const loadError = ref('')
const data = ref<DashboardData | null>(null)
const counselorWarnings = ref<WarningRecord[]>([])
const now = ref(new Date())
function hasRole(...allowedRoles: string[]) {
return roles.value.some((role) => allowedRoles.includes(role))
}
const isCounselorDashboard = computed(() =>
hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'),
)
const activeCounselorWarnings = computed(() =>
counselorWarnings.value.filter((warning) => warning.status === 1),
)
function parseDateOnly(value?: string) {
if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
@@ -342,6 +380,19 @@ const readiness = computed(() => {
]
})
const operationAlerts = computed(() => {
const counts = data.value?.counts
if (!counts) return []
const alerts = [] as Array<{ level: 'critical' | 'warning'; title: string; detail: string; route: string }>
const unpublished = counts.teachingTasks - counts.publishedTeachingTasks
const unscheduled = counts.publishedTeachingTasks - counts.scheduledTeachingTasks
if (unpublished > 0) alerts.push({ level: 'warning', title: '教学任务尚未发布', detail: `${unpublished} 个教学班尚未发布,后续排课与选课无法推进。`, route: '/teaching-tasks' })
if (unscheduled > 0) alerts.push({ level: 'critical', title: '课表覆盖存在缺口', detail: `${unscheduled} 个已发布教学班尚未进入课表。`, route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable' })
if (counts.submittedGradeSheets > 0) alerts.push({ level: 'warning', title: '成绩审核等待处理', detail: `${counts.submittedGradeSheets} 张成绩登记册已提交,等待审核或发布。`, route: '/grades' })
if (counts.openCourseSelectionRounds > 0 && counts.courseEnrollments === 0) alerts.push({ level: 'warning', title: '开放选课尚无有效记录', detail: `${counts.openCourseSelectionRounds} 个选课批次开放中,但当前未发现有效选课。`, route: '/course-selections' })
return alerts
})
const primaryActionRoute = computed(() =>
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
)
@@ -351,6 +402,11 @@ async function loadDashboard() {
loadError.value = ''
try {
data.value = (await http.get<DashboardData>('/dashboard')).data
if (isCounselorDashboard.value) {
counselorWarnings.value = (await http.get<WarningRecord[]>('/warnings/records', {
params: { academicTermId: data.value.currentTerm?.id },
})).data
}
} catch {
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
} finally {
@@ -359,7 +415,7 @@ async function loadDashboard() {
}
onMounted(async () => {
if (isStudentOverview.value) {
if (isStudentOverview.value || isTeacherOverview.value) {
loading.value = false
return
}
@@ -369,6 +425,7 @@ onMounted(async () => {
<template>
<StudentDashboardView v-if="isStudentOverview" />
<TeacherDashboardView v-else-if="isTeacherOverview" />
<div v-else v-loading="loading" class="admin-dashboard">
<el-result
@@ -390,8 +447,8 @@ onMounted(async () => {
<i>数据范围</i>
</div>
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p>
<h1>{{ data.audience.title }}</h1>
<p class="overview-description">{{ data.audience.description }}</p>
<h1>{{ data.greeting.title }}</h1>
<p class="overview-description">{{ data.greeting.subtitle }}</p>
</div>
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
@@ -436,6 +493,23 @@ onMounted(async () => {
</div>
</section>
<section class="greeting-insights" :aria-label="data.greeting.label">
<span class="greeting-insights-label">{{ data.greeting.label }}</span>
<div>
<article
v-for="insight in data.greeting.insights"
:key="insight.label"
:class="`greeting-insight ${insight.tone}`"
>
<span>{{ insight.label }}</span>
<strong>{{ insight.value }}</strong>
<small>{{ insight.hint }}</small>
</article>
</div>
</section>
<p class="greeting-narrative">{{ data.greeting.narrative }}</p>
<section class="overview-metrics" aria-label="关键教学数据">
<button
v-for="metric in adminMetrics"
@@ -450,7 +524,38 @@ onMounted(async () => {
</button>
</section>
<section class="operation-alerts">
<header><div><span>OPERATION WATCH</span><h2>运行预警</h2></div><small>{{ operationAlerts.length ? `${operationAlerts.length} 个运行断点需要关注` : '当前未发现运行断点' }}</small></header>
<div v-if="operationAlerts.length" class="operation-alert-list">
<button v-for="alert in operationAlerts" :key="alert.title" :class="alert.level" @click="router.push(alert.route)"><b>{{ alert.title }}</b><span>{{ alert.detail }}</span><i>立即处理 </i></button>
</div>
<p v-else>教学任务课表与成绩流程当前衔接正常</p>
</section>
<section class="dashboard-work-grid">
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
<header class="panel-heading">
<div>
<span class="panel-index">RADAR / COUNSELOR</span>
<h2>需重点关注的学生</h2>
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
</div>
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 </button>
</header>
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
<span>{{ warning.className }}</span>
<b>{{ warning.studentName }}</b>
<small>{{ warning.detail }}</small>
<i>查看 </i>
</button>
</div>
<div v-else class="todo-empty">
<el-icon><Checked /></el-icon>
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里</span></div>
</div>
</article>
<article class="dashboard-panel todo-panel">
<header class="panel-heading">
<div>
@@ -586,6 +691,62 @@ onMounted(async () => {
background-size: 32px 32px, 32px 32px, auto;
}
.greeting-insights {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
padding: 17px 21px;
border: 1px solid #dce6eb;
background: #f7faf9;
}
.greeting-insights-label {
align-self: center;
color: var(--dashboard-teal);
font: 700 10px/1.5 Consolas, monospace;
letter-spacing: .12em;
}
.greeting-insights > div {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.greeting-insight {
min-width: 0;
padding-left: 13px;
display: grid;
gap: 3px;
border-left: 2px solid #a9bcc6;
}
.greeting-insight.positive { border-color: var(--dashboard-teal); }
.greeting-insight.attention { border-color: var(--dashboard-amber); }
.greeting-insight span { color: #687788; font-size: 11px; }
.greeting-insight strong { color: var(--dashboard-navy); font-size: 20px; }
.greeting-insight small { overflow: hidden; color: #84909d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.greeting-narrative {
margin: -7px 0 0;
padding: 0 3px;
color: #526078;
font-size: 12px;
line-height: 1.7;
}
.operation-alerts { padding: 21px 24px; border: 1px solid #e5e8ed; background: #fff; }
.operation-alerts header { display: flex; justify-content: space-between; gap: 16px; align-items: end; }
.operation-alerts header span { color: var(--dashboard-teal); font: 700 10px/1 Consolas,monospace; letter-spacing: .12em; }
.operation-alerts h2 { margin: 7px 0 0; color: var(--dashboard-navy); font-size: 19px; }
.operation-alerts header small { color: #697789; font-size: 11px; }
.operation-alert-list { margin-top: 16px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
.operation-alert-list button { padding: 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #e8e1d3; border-left: 3px solid var(--dashboard-amber); background: #fffcf6; }
.operation-alert-list button.critical { border-left-color: #ba5145; background: #fff9f8; }
.operation-alert-list b { color: #334254; font-size: 13px; }.operation-alert-list span { color: #6d7888; font-size: 11px; line-height: 1.5; }.operation-alert-list i { color: #8b6d3a; font-size: 11px; font-style: normal; }
.operation-alerts > p { margin: 15px 0 0; color: #627184; font-size: 12px; }
.overview-hero::after {
content: "";
position: absolute;
@@ -866,6 +1027,28 @@ onMounted(async () => {
background: var(--dashboard-paper);
}
.counselor-radar { border-top: 3px solid var(--dashboard-amber); }
.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; }
.radar-link:hover { color: var(--dashboard-blue); }
.counselor-risk-list { display: grid; }
.counselor-risk-list button {
padding: 13px 0;
display: grid;
grid-template-columns: 76px 68px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
text-align: left;
border: 0;
border-bottom: 1px solid #eee8dc;
background: transparent;
}
.counselor-risk-list button:last-child { border-bottom: 0; }
.counselor-risk-list button:hover b { color: var(--dashboard-blue); }
.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; }
.counselor-risk-list b { color: #3a4758; font-size: 13px; }
.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; }
.todo-panel,
.quick-panel,
.readiness-panel {
@@ -1155,6 +1338,7 @@ button:focus-visible {
}
@media (max-width: 1100px) {
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
.dashboard-work-grid {
grid-template-columns: 1fr;
}
@@ -1170,6 +1354,13 @@ button:focus-visible {
}
@media (max-width: 760px) {
.operation-alerts { padding: 18px; }
.operation-alert-list { grid-template-columns: 1fr; }
.greeting-insights { padding: 15px; }
.greeting-insights > div { grid-template-columns: 1fr; }
.counselor-risk-list button { grid-template-columns: 1fr auto; }
.counselor-risk-list span { grid-column: 1 / -1; }
.counselor-risk-list small { white-space: normal; }
.admin-dashboard { gap: 10px; }
.overview-hero {
+148 -50
View File
@@ -19,12 +19,21 @@ import {
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const canViewAllColleges = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
)
const loading = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const terms = ref<any[]>([])
const colleges = ref<any[]>([])
const termId = ref('')
const statusFilter = ref('')
const collegeId = ref('')
const projectKeyword = ref('')
const projectPage = ref(1)
const projectPageSize = ref(20)
const projectTotal = ref(0)
const projects = ref<any[]>([])
const studentResults = ref<any[]>([])
const detailDrawer = ref(false)
@@ -177,12 +186,20 @@ async function load() {
if (isStudent.value) {
studentResults.value = (await http.get('/experiment-grades/mine')).data
} else {
projects.value = (await http.get('/experiment-grades/management', {
const data = (await http.get('/experiment-grades/management', {
params: {
academicTermId: termId.value || undefined,
status: statusFilter.value || undefined,
collegeId: collegeId.value || undefined,
keyword: projectKeyword.value.trim() || undefined,
page: projectPage.value,
pageSize: projectPageSize.value,
},
})).data
projects.value = data.items
projectTotal.value = data.total
projectPage.value = data.page
projectPageSize.value = data.pageSize
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
@@ -359,7 +376,7 @@ async function publishSheet() {
}
}
function scoreTone(result: any) {
function projectScoreTone(result: any) {
if (result.totalScore == null) return 'is-pending'
return result.isPassed ? 'is-passed' : 'is-failed'
}
@@ -367,7 +384,11 @@ function scoreTone(result: any) {
onMounted(async () => {
if (!isStudent.value) {
try {
terms.value = (await http.get('/base-data/terms')).data
const requests: Promise<any>[] = [http.get('/base-data/terms')]
if (canViewAllColleges.value) requests.push(http.get('/base-data/colleges'))
const [termResponse, collegeResponse] = await Promise.all(requests)
terms.value = termResponse.data
colleges.value = collegeResponse?.data ?? []
termId.value = defaultAcademicTermId(terms.value) ?? ''
} catch (error) {
ElMessage.error(apiErrorMessage(error))
@@ -383,13 +404,13 @@ onMounted(async () => {
<div>
<span class="section-kicker">LAB ASSESSMENT LEDGER</span>
<h2>{{ isStudent ? '我的实验成绩' : '实验成绩管理' }}</h2>
<p v-if="isStudent">实验成绩独立记录操作结果报告和到场情况不与课程总评混在一起</p>
<p v-if="isStudent">按课程查看各实验项目实验课总分为项目贡献权重加权平均也是课程成绩引用的实验部分分数</p>
<p v-else>按实验项目建立独立评分方案审核发布后可选择汇总到课程成绩单</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<section class="assessment-flow" aria-label="实验成绩流程">
<section v-if="!isStudent" class="assessment-flow" aria-label="实验成绩流程">
<div><span>PROJECT</span><b>实验项目</b><small>集中安排 / 自主预约</small></div>
<i></i>
<div><span>RUBRIC</span><b>独立评分</b><small>操作 · 结果 · 报告 · 规范</small></div>
@@ -398,39 +419,76 @@ onMounted(async () => {
</section>
<template v-if="isStudent">
<section v-loading="loading" class="student-result-grid">
<article
v-for="result in studentResults"
:key="result.id"
class="student-result-card"
:class="scoreTone(result)"
>
<header>
<section v-loading="loading" class="student-course-results">
<article v-for="course in studentResults" :key="course.teachingTaskId" class="student-course-result">
<header class="student-course-head">
<div>
<span>{{ result.projectCode }} · {{ modeLabels[result.arrangementMode] }}</span>
<h3>{{ result.projectName }}</h3>
<p>{{ result.courseCode }} · {{ result.courseName }} · {{ result.termName }}</p>
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
<p>{{ course.publishedProjectCount }} 个已发布实验项目 · 按项目贡献权重计算</p>
</div>
<div class="score-seal">
<strong>{{ result.totalScore ?? '—' }}</strong>
<small>{{ result.totalScore == null ? participationLabels[result.participationStatus] : result.isPassed ? '通过' : '未通过' }}</small>
<div class="course-score-seal" :class="{ 'has-score': course.experimentCourseScore != null }">
<span>实验课总分</span>
<strong>{{ course.experimentCourseScore ?? '—' }}</strong>
<small>{{ course.experimentCourseScore == null ? '成绩尚未完整' : '加权平均分' }}</small>
</div>
</header>
<div class="result-facts">
<span>参与状态<b>{{ participationLabels[result.participationStatus] }}</b></span>
<span>合格线<b>{{ result.passScore }}</b></span>
<span>实验次数<b>{{ result.attemptNumber }}</b></span>
<span>提交情况<b>{{ result.isLate ? '迟交' : result.submittedAt ? '已提交' : '未登记' }}</b></span>
</div>
<div class="result-rubric">
<div v-for="item in result.items" :key="item.experimentGradeItemId">
<span>{{ item.name }} · {{ item.weight }}%</span>
<b>{{ item.score ?? '—' }}</b>
<small v-if="item.comment">{{ item.comment }}</small>
</div>
</div>
<p v-if="result.safetyViolation" class="safety-alert">已登记安全违规本次实验按规则判定为 0 </p>
<p v-if="result.teacherComment" class="teacher-comment">{{ result.teacherComment }}</p>
<el-table :data="course.projects" class="student-grade-table" size="small">
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div class="student-grade-detail" :class="projectScoreTone(row)">
<div class="result-facts">
<span>参与状态<b>{{ participationLabels[row.participationStatus] }}</b></span>
<span>合格线<b>{{ row.passScore }}</b></span>
<span>实验次数<b>{{ row.attemptNumber }}</b></span>
<span>提交情况<b>{{ row.isLate ? '迟交' : row.submittedAt ? '已提交' : '未登记' }}</b></span>
</div>
<div class="result-rubric">
<div v-for="item in row.items" :key="item.experimentGradeItemId">
<span>{{ item.name }} · {{ item.weight }}%</span>
<b>{{ item.score ?? '—' }}</b>
<small v-if="item.comment">{{ item.comment }}</small>
</div>
</div>
<p v-if="row.safetyViolation" class="safety-alert">已登记安全违规本次实验按规则判定为 0 </p>
<p v-if="row.teacherComment" class="teacher-comment">{{ row.teacherComment }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="220">
<template #default="{ row }">
<div class="student-grade-project">
<b>{{ row.projectCode }} · {{ row.projectName }}</b>
<small>{{ modeLabels[row.arrangementMode] }} · 贡献权重 {{ row.contributionWeight }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="分项成绩" min-width="300">
<template #default="{ row }">
<div class="rubric-inline">
<span v-for="item in row.items" :key="item.experimentGradeItemId">
{{ item.name }} <b>{{ item.score ?? '—' }}</b>
</span>
</div>
</template>
</el-table-column>
<el-table-column label="参与状态" width="100" align="center">
<template #default="{ row }">{{ participationLabels[row.participationStatus] }}</template>
</el-table-column>
<el-table-column label="项目成绩" width="100" align="center">
<template #default="{ row }">
<strong class="project-total" :class="projectScoreTone(row)">{{ row.totalScore ?? '—' }}</strong>
</template>
</el-table-column>
<el-table-column label="结果" width="88" align="center">
<template #default="{ row }">
<el-tag v-if="row.totalScore != null" :type="row.isPassed ? 'success' : 'danger'" size="small">
{{ row.isPassed ? '通过' : '未通过' }}
</el-tag>
<span v-else class="table-muted">待完整</span>
</template>
</el-table-column>
</el-table>
</article>
<el-empty v-if="!loading && !studentResults.length" description="暂无已发布的实验成绩" />
</section>
@@ -438,7 +496,7 @@ onMounted(async () => {
<template v-else>
<section class="grade-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-select v-model="termId" clearable placeholder="全部学期" @change="projectPage = 1; load()">
<el-option
v-for="term in terms"
:key="term.id"
@@ -447,10 +505,27 @@ onMounted(async () => {
:class="academicTermOptionClass(term)"
/>
</el-select>
<el-select v-model="statusFilter" clearable placeholder="全部成绩状态" @change="load">
<el-select v-model="statusFilter" clearable placeholder="全部成绩状态" @change="projectPage = 1; load()">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<span>每个实验项目独立建单课程只读取已发布的汇总快照</span>
<el-select
v-if="canViewAllColleges"
v-model="collegeId"
clearable
filterable
placeholder="全部开课学院"
@change="projectPage = 1; load()"
>
<el-option v-for="college in colleges" :key="college.id" :label="college.name" :value="college.id" />
</el-select>
<el-input
v-model="projectKeyword"
clearable
placeholder="项目、课程、教学班或教师"
@keyup.enter="projectPage = 1; load()"
@clear="projectPage = 1; load()"
/>
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
</section>
<section v-loading="loading" class="grade-project-list">
@@ -488,6 +563,15 @@ onMounted(async () => {
</article>
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
</section>
<el-pagination
v-model:current-page="projectPage"
v-model:page-size="projectPageSize"
:total="projectTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="projectPage = 1; load()"
/>
</template>
<el-dialog
@@ -724,6 +808,7 @@ onMounted(async () => {
.assessment-flow > i { display: grid; place-items: center; color: #8aa0aa; font-style: normal; background: #f4f7f8; }
.grade-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.grade-toolbar .el-select { width: 220px; }
.grade-toolbar .el-input { width: min(320px, 100%); }
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
.grade-project-list { display: grid; gap: 12px; min-height: 180px; }
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); }
@@ -737,18 +822,31 @@ onMounted(async () => {
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
.sheet-progress b { color: var(--grade-teal); }
.sheet-empty b { color: #5d7480; }
.student-result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 15px; min-height: 180px; }
.student-result-card { overflow: hidden; border: 1px solid #d8e4e6; border-top: 5px solid #80939b; background: #fff; }
.student-result-card.is-passed { border-top-color: var(--grade-teal); }
.student-result-card.is-failed { border-top-color: #b5543d; }
.student-result-card > header { padding: 18px; display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e4eaed; }
.student-result-card header span { color: #607d89; font: 700 10px/1.3 Consolas, monospace; }
.student-result-card h3 { margin: 5px 0; color: var(--grade-ink); font-size: 18px; }
.student-result-card header p { margin: 0; color: var(--muted); font-size: 12px; }
.score-seal { width: 72px; flex: 0 0 72px; display: grid; place-content: center; border-left: 1px solid #dce6e8; text-align: center; }
.score-seal strong { color: var(--grade-teal); font: 750 28px/1 Consolas, monospace; }
.is-failed .score-seal strong { color: #b5543d; }
.score-seal small { margin-top: 5px; color: var(--muted); }
.student-course-results { display: grid; gap: 12px; min-height: 180px; }
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
.student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
.student-course-head > div:first-child { min-width: 0; }
.student-course-head span { color: #617b87; font: 700 10px/1.3 Consolas, monospace; letter-spacing: .05em; }
.student-course-head h3 { margin: 4px 0; color: var(--grade-ink); font-size: 16px; }
.student-course-head p { margin: 0; color: #647b85; font-size: 11px; }
.course-score-seal { min-width: 132px; display: grid; grid-template-columns: auto auto; align-items: center; gap: 2px 12px; padding: 7px 11px; border-left: 1px solid #cbdade; text-align: right; }
.course-score-seal span { grid-column: 1; color: #59717d; font: 600 11px/1.2 inherit; letter-spacing: 0; }
.course-score-seal strong { grid-column: 2; grid-row: 1 / span 2; color: #768b94; font: 750 28px/1 Consolas, monospace; }
.course-score-seal.has-score strong { color: var(--grade-teal); }
.course-score-seal small { grid-column: 1; color: var(--muted); font-size: 10px; }
.student-grade-table { --el-table-border-color: #dce5e8; --el-table-header-bg-color: #f7fafb; width: 100%; }
.student-grade-table :deep(th.el-table__cell) { padding: 7px 0; color: #637985; font-size: 11px; }
.student-grade-table :deep(td.el-table__cell) { padding: 8px 0; }
.student-grade-project { display: grid; gap: 3px; }
.student-grade-project b { color: var(--grade-ink); font-size: 12px; }
.student-grade-project small { color: #6a8089; font-size: 10px; }
.rubric-inline { display: flex; flex-wrap: wrap; gap: 4px 10px; }
.rubric-inline span { color: #6a7f88; font-size: 10px; }
.rubric-inline b { color: #294e5d; font-size: 11px; }
.project-total { font: 750 15px/1 Consolas, monospace; color: #74858c; }
.project-total.is-passed { color: var(--grade-teal); }
.project-total.is-failed { color: #b5543d; }
.student-grade-detail { padding: 12px 16px 14px; background: #f7faf9; }
.result-facts { padding: 12px 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; background: #f5f8f8; }
.result-facts span { display: grid; gap: 3px; color: #758991; font-size: 10px; }
.result-facts b { color: #365762; font-size: 12px; }
@@ -812,7 +910,7 @@ onMounted(async () => {
.assessment-flow { grid-template-columns: 1fr; }
.assessment-flow > i { display: none; }
.grade-toolbar { align-items: stretch; flex-direction: column; }
.grade-toolbar .el-select { width: 100%; }
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
.grade-toolbar > span { margin-left: 0; }
.grade-project-card { grid-template-columns: 1fr; }
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
+538 -220
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Calendar, Check, Plus, Refresh, Tickets, User } from '@element-plus/icons-vue'
import { Calendar, Check, Plus, Refresh, User } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
@@ -16,14 +16,24 @@ const loading = ref(false)
const terms = ref<any[]>([])
const termId = ref('')
const projects = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const selectedProjectIds = ref<string[]>([])
const options = reactive({
tasks: [] as any[],
colleges: [] as any[],
scheduleEntries: [] as any[],
classrooms: [] as any[],
periods: [] as any[],
})
const modeFilter = ref('')
const statusFilter = ref('')
const offeringCollegeId = ref('')
const courseKeyword = ref('')
const classKeyword = ref('')
const teacherKeyword = ref('')
const taskKeyword = ref('')
const projectDialog = ref(false)
const editingProjectId = ref('')
@@ -32,12 +42,18 @@ const projectForm = reactive({
scheduleEntryId: '',
code: '',
name: '',
projectNames: '',
nameMode: 'Sequence',
arrangementMode: 'Centralized',
description: '',
requirements: '',
dates: [] as string[],
})
const batchRenameDialog = ref(false)
const batchRenameProjectIds = ref<string[]>([])
const batchRenameNames = ref('')
const sessionDialog = ref(false)
const selectedProject = ref<any>(null)
const sessionForm = reactive({
@@ -68,6 +84,54 @@ const batchProjectOptions = computed(() =>
projects.value.filter((project) =>
project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'),
)
const taskGroups = computed(() => {
const groups = new Map<string, any>()
options.tasks.forEach((task) => {
const key = `${task.academicTermId}-${task.courseId}`
const group = groups.get(key) ?? {
key,
termName: task.termName,
courseCode: task.courseCode,
courseName: task.courseName,
tasks: [],
}
group.tasks.push(task)
groups.set(key, group)
})
return [...groups.values()]
})
const projectGroups = computed(() => {
const groups = new Map<string, any>()
projects.value.forEach((project) => {
const classKey = [...(project.classNames ?? [])].sort().join('|')
const key = `${project.academicTermId}-${project.courseCode}-${classKey}`
const group = groups.get(key) ?? {
key,
termName: project.termName,
courseCode: project.courseCode,
courseName: project.courseName,
classNames: project.classNames,
teacherNames: project.teacherNames,
taskNumbers: [],
projects: [],
}
if (!group.taskNumbers.includes(project.taskNumber)) group.taskNumbers.push(project.taskNumber)
group.projects.push(project)
groups.set(key, group)
})
return [...groups.values()]
})
const draftSelectedProjectIds = computed(() =>
selectedProjectIds.value.filter((id) =>
projects.value.some((project) => project.id === id && project.status === 'Draft')),
)
const batchRenameProjects = computed(() =>
projects.value.filter((project) => batchRenameProjectIds.value.includes(project.id)),
)
const allPageDraftSelected = computed(() => {
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
return ids.length > 0 && ids.every((id) => selectedProjectIds.value.includes(id))
})
const activePeriods = computed(() =>
options.periods.filter((period) =>
!selectedProject.value
@@ -99,6 +163,8 @@ function resetProjectForm() {
scheduleEntryId: '',
code: '',
name: '',
projectNames: '',
nameMode: 'Sequence',
arrangementMode: 'Centralized',
description: '',
requirements: '',
@@ -119,6 +185,11 @@ function onScheduleEntryChange() {
onTaskChange()
}
function formatScheduleEntry(entry: any) {
const classes = entry.classNames?.join('、') || '选课学生'
return `${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · ${classes} · 星期 ${entry.dayOfWeek}${entry.startPeriod}${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`
}
function openCreateProject() {
resetProjectForm()
projectDialog.value = true
@@ -131,6 +202,8 @@ function openEditProject(project: any) {
scheduleEntryId: project.scheduleEntryId ?? '',
code: project.code,
name: project.name,
projectNames: '',
nameMode: 'Sequence',
arrangementMode: project.arrangementMode,
description: project.description ?? '',
requirements: project.requirements ?? '',
@@ -139,12 +212,22 @@ function openEditProject(project: any) {
projectDialog.value = true
}
function projectNameLines(value: string) {
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
}
async function saveProject() {
const names = projectNameLines(projectForm.projectNames)
const hasValidName = editingProjectId.value
? !!projectForm.name.trim()
: projectForm.nameMode === 'InputNames'
? names.length > 0
: !!projectForm.name.trim()
if (!projectForm.teachingTaskIds.length ||
(projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
!projectForm.code.trim()
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
ElMessage.warning(projectForm.arrangementMode === 'Centralized'
|| !hasValidName || projectForm.dates.length !== 2) {
ElMessage.warning(projectForm.arrangementMode === 'Centralized' && editingProjectId.value
? '请选择课表实验课,并填写项目编码、名称和开放日期'
: '请填写教学任务、项目编码、名称和开放日期')
return
@@ -153,7 +236,7 @@ async function saveProject() {
teachingTaskId: projectForm.teachingTaskIds[0],
scheduleEntryId: projectForm.scheduleEntryId || null,
code: projectForm.code,
name: projectForm.name,
name: projectForm.nameMode === 'InputNames' && !editingProjectId.value ? names[0] : projectForm.name,
arrangementMode: projectForm.arrangementMode,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
@@ -164,16 +247,17 @@ async function saveProject() {
if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新')
} else if (projectForm.arrangementMode === 'Centralized') {
await http.post('/experiments', payload)
ElMessage.success('实验项目已绑定课表实验课')
} else {
await http.post('/experiments/batch', {
...payload,
teachingTaskId: undefined,
teachingTaskIds: projectForm.teachingTaskIds,
names: projectForm.nameMode === 'InputNames' ? names : undefined,
generateSequentialNames: projectForm.nameMode === 'Sequence',
})
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
ElMessage.success(projectForm.arrangementMode === 'Centralized'
? `已按 ${projectForm.teachingTaskIds.length} 个教学班的全部实验课生成项目`
: `已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
}
projectDialog.value = false
await load()
@@ -336,6 +420,100 @@ async function deleteProject(project: any) {
}
}
function openBatchRename() {
batchRenameProjectIds.value = projects.value
.filter((project) => draftSelectedProjectIds.value.includes(project.id))
.map((project) => project.id)
batchRenameNames.value = batchRenameProjects.value.map((project) => project.name).join('\n')
batchRenameDialog.value = true
}
async function saveBatchRename() {
const names = projectNameLines(batchRenameNames.value)
if (names.length !== batchRenameProjectIds.value.length) {
ElMessage.warning(`已选 ${batchRenameProjectIds.value.length} 个项目,请粘贴 ${batchRenameProjectIds.value.length} 行项目名称`)
return
}
try {
await http.put('/experiments/batch/names', { projectIds: batchRenameProjectIds.value, names })
ElMessage.success('实验项目名称已批量更新')
batchRenameDialog.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function togglePageDraftSelection(value: unknown) {
const ids = projects.value.filter((project) => project.status === 'Draft').map((project) => project.id)
selectedProjectIds.value = Boolean(value)
? [...new Set([...selectedProjectIds.value, ...ids])]
: selectedProjectIds.value.filter((id) => !ids.includes(id))
}
function toggleProjectSelection(projectId: string, value: unknown) {
selectedProjectIds.value = Boolean(value)
? [...new Set([...selectedProjectIds.value, projectId])]
: selectedProjectIds.value.filter((id) => id !== projectId)
}
async function publishSelectedProjects() {
const ids = draftSelectedProjectIds.value
if (!ids.length) return
try {
await ElMessageBox.confirm(
`将发布选中的 ${ids.length} 个草稿实验项目,并通知对应学生。`,
'批量发布实验项目',
{ confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' },
)
const { data } = await http.post('/experiments/batch/publish', { projectIds: ids })
ElMessage.success(`已提交 ${ids.length} 个实验项目的后台发布任务`)
selectedProjectIds.value = []
void pollPublishJob(data.jobId)
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function pollPublishJob(jobId: string) {
try {
const { data } = await http.get(`/experiments/batch/publish-jobs/${jobId}`)
if (data.status === 'Succeeded') {
ElMessage.success('实验项目已完成发布')
await load()
return
}
if (data.status === 'Failed') {
ElMessage.error(data.errorMessage || '实验项目发布失败')
await load()
return
}
window.setTimeout(() => { void pollPublishJob(jobId) }, 1200)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteSelectedProjects() {
const ids = draftSelectedProjectIds.value
if (!ids.length) return
try {
await ElMessageBox.confirm(
`将删除选中的 ${ids.length} 个草稿实验项目及其未发布场次。`,
'批量删除实验项目',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
)
await http.delete('/experiments/batch', { data: { projectIds: ids } })
ElMessage.success(`已删除 ${ids.length} 个实验项目`)
selectedProjectIds.value = []
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function cancelSession(project: any, session: any) {
try {
await ElMessageBox.confirm(
@@ -424,14 +602,20 @@ async function loadOptions() {
if (isStudent.value) return
try {
const { data } = await http.get('/experiments/options', {
params: { academicTermId: termId.value || undefined },
params: {
academicTermId: termId.value || undefined,
offeringCollegeId: offeringCollegeId.value || undefined,
courseKeyword: courseKeyword.value.trim() || undefined,
},
})
options.tasks = data.tasks
options.colleges = data.colleges
options.scheduleEntries = data.scheduleEntries
options.classrooms = data.classrooms
options.periods = data.periods
} catch (error) {
options.tasks = []
options.colleges = []
options.classrooms = []
options.periods = []
ElMessage.error(apiErrorMessage(error))
@@ -445,17 +629,28 @@ async function load() {
projects.value = (await http.get('/experiments/student', {
params: { academicTermId: termId.value || undefined },
})).data
total.value = projects.value.length
} else {
projects.value = (await http.get('/experiments/management', {
const { data } = await http.get('/experiments/management', {
params: {
academicTermId: termId.value || undefined,
arrangementMode: modeFilter.value || undefined,
status: statusFilter.value || undefined,
offeringCollegeId: offeringCollegeId.value || undefined,
courseKeyword: courseKeyword.value.trim() || undefined,
classKeyword: classKeyword.value.trim() || undefined,
teacherKeyword: teacherKeyword.value.trim() || undefined,
taskKeyword: taskKeyword.value.trim() || undefined,
page: page.value,
pageSize: pageSize.value,
},
})).data
})
projects.value = data.items
total.value = data.total
}
} catch (error) {
projects.value = []
total.value = 0
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
@@ -463,10 +658,30 @@ async function load() {
}
async function changeTerm() {
page.value = 1
selectedProjectIds.value = []
await loadOptions()
await load()
}
async function searchTaskOptions() {
page.value = 1
selectedProjectIds.value = []
await loadOptions()
await load()
}
async function changePage(nextPage: number) {
page.value = nextPage
selectedProjectIds.value = []
await load()
}
async function changePageSize(size: number) {
pageSize.value = size
await changePage(1)
}
onMounted(async () => {
try {
terms.value = (await http.get('/base-data/terms')).data
@@ -499,7 +714,7 @@ onMounted(async () => {
</div>
</section>
<section class="lab-rail" aria-label="实验安排方式说明">
<section v-if="!isStudent" class="lab-rail" aria-label="实验安排方式说明">
<article class="rail-lane centralized">
<span class="rail-code">FIXED / 集中</span>
<b>统一课次</b>
@@ -524,6 +739,13 @@ onMounted(async () => {
/>
</el-select>
<template v-if="!isStudent">
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
</el-select>
<el-input v-model="courseKeyword" clearable placeholder="课程名称或编码" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-input v-model="classKeyword" clearable placeholder="教学班" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-input v-model="teacherKeyword" clearable placeholder="任课教师" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-input v-model="taskKeyword" clearable placeholder="教学任务号" @keyup.enter="searchTaskOptions" @clear="searchTaskOptions" />
<el-select v-model="modeFilter" clearable placeholder="全部安排方式" @change="load">
<el-option label="集中安排" value="Centralized" />
<el-option label="自行安排" value="SelfScheduled" />
@@ -534,208 +756,176 @@ onMounted(async () => {
<el-option label="已关闭" value="Closed" />
</el-select>
</template>
<span class="result-note"> {{ projects.length }} 个实验项目</span>
<span class="result-note">{{ isStudent ? `${projectGroups.length} 门课程、${total} 个实验项目` : `${total} 个教学班课程` }}</span>
</section>
<section v-if="!isStudent" class="experiment-batch-bar">
<el-checkbox :model-value="allPageDraftSelected" :indeterminate="!!draftSelectedProjectIds.length && !allPageDraftSelected" @change="togglePageDraftSelection">
选中当前页全部草稿
</el-checkbox>
<span>已选 {{ draftSelectedProjectIds.length }} 个草稿项目</span>
<el-button size="small" :disabled="!draftSelectedProjectIds.length" @click="openBatchRename">批量改名</el-button>
<el-button size="small" type="primary" :disabled="!draftSelectedProjectIds.length" @click="publishSelectedProjects">批量发布</el-button>
<el-button size="small" type="danger" plain :disabled="!draftSelectedProjectIds.length" @click="deleteSelectedProjects">批量删除</el-button>
</section>
<section v-loading="loading" class="project-list">
<article
v-for="project in projects"
:key="project.id"
class="project-card"
:class="[
project.arrangementMode === 'Centralized' ? 'is-centralized' : 'is-flexible',
`is-${project.status.toLowerCase()}`,
]"
>
<header class="project-head">
<div class="project-identity">
<span class="project-code">{{ project.courseCode }} · {{ project.code }}</span>
<h3>{{ project.name }}</h3>
<p>{{ project.courseName }} · {{ project.taskNumber }}</p>
<section v-for="group in projectGroups" :key="group.key" class="project-group">
<header class="project-group-head">
<div>
<span>{{ group.termName }}</span>
<h3>{{ group.courseCode }} · {{ group.courseName }}</h3>
</div>
<div class="project-tags">
<el-tag
:type="project.arrangementMode === 'Centralized' ? 'primary' : 'success'"
effect="plain"
>
{{ modeMeta[project.arrangementMode].label }}
</el-tag>
<el-tag
:type="project.status === 'Published' ? 'success' : project.status === 'Closed' ? 'info' : 'warning'"
>
{{ statusLabels[project.status] }}
</el-tag>
<div class="project-group-task">
<b>{{ group.taskNumbers.join('、') }}</b>
<span>{{ group.classNames?.join('、') || '选课学生' }}</span>
</div>
</header>
<div class="project-context">
<span><b>开放日期</b>{{ project.startDate }} {{ project.endDate }}</span>
<span><b>任课教师</b>{{ project.teacherNames.join('、') || '待定' }}</span>
<span v-if="!isStudent"><b>面向班级</b>{{ project.classNames.join('、') || '选课学生' }}</span>
</div>
<p v-if="project.description" class="project-description">{{ project.description }}</p>
<div v-if="project.requirements" class="requirement-strip">
<b>实验要求</b>
<span>{{ project.requirements }}</span>
</div>
<div class="session-board">
<div class="session-board-head">
<div>
<span>{{ project.arrangementMode === 'Centralized' ? '统一实验课次' : '可选实验场次' }}</span>
<b>{{ modeMeta[project.arrangementMode].note }}</b>
</div>
<el-button
v-if="!isStudent && project.status !== 'Closed' && project.arrangementMode === 'SelfScheduled'"
size="small"
:icon="Calendar"
@click="openSession(project)"
>
安排场次
</el-button>
</div>
<div v-if="project.arrangementMode === 'Centralized' && project.scheduleEntry" class="session-grid">
<article class="session-ticket">
<div class="ticket-date"><strong>课表</strong><span>固定</span></div>
<div class="ticket-body">
<b>星期 {{ project.scheduleEntry.dayOfWeek }} · {{ project.scheduleEntry.startPeriod }}{{ project.scheduleEntry.startPeriod + project.scheduleEntry.periodCount - 1 }} </b>
<span> {{ project.scheduleEntry.startWeek }}{{ project.scheduleEntry.endWeek }} · {{ project.scheduleEntry.campusName }} · {{ project.scheduleEntry.buildingName }} {{ project.scheduleEntry.classroomName }}</span>
</div>
<div class="ticket-action"><el-tag type="primary" size="small" effect="plain">课表已安排</el-tag></div>
</article>
</div>
<div v-else-if="project.sessions.length" class="session-grid">
<article
v-for="session in project.sessions"
:key="session.id"
class="session-ticket"
:class="{
'is-selected': project.myBooking?.experimentSessionId === session.id,
'is-cancelled': session.status === 'Cancelled',
}"
>
<div class="ticket-date">
<strong>{{ session.sessionDate.slice(8, 10) }}</strong>
<span>{{ session.sessionDate.slice(5, 7) }} </span>
</div>
<div class="ticket-body">
<b> {{ session.startPeriod }}{{ session.startPeriod + session.periodCount - 1 }} </b>
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
<small v-if="session.notes">{{ session.notes }}</small>
</div>
<div class="ticket-action">
<template v-if="project.arrangementMode === 'SelfScheduled'">
<el-tag
v-if="session.status !== 'Cancelled'"
:type="sessionTagType(session)"
size="small"
effect="plain"
>
{{ session.capacity - session.reservedCount }} / {{ session.capacity }}
</el-tag>
<el-tag v-else type="info" size="small">已取消</el-tag>
</template>
<el-tag v-else type="primary" size="small" effect="plain">
统一到场
</el-tag>
<template v-if="isStudent && project.arrangementMode === 'SelfScheduled'">
<div class="project-group-projects">
<el-table v-if="!isStudent" :data="group.projects" class="project-table" size="small">
<el-table-column width="46" align="center">
<template #default="{ row }">
<el-checkbox
v-if="row.status === 'Draft'"
:model-value="selectedProjectIds.includes(row.id)"
@change="toggleProjectSelection(row.id, $event)"
aria-label="选择实验项目"
/>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="220">
<template #default="{ row }">
<div class="table-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small v-if="row.description">{{ row.description }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="实验课次 / 场次" min-width="280">
<template #default="{ row }">
<div v-if="row.arrangementMode === 'Centralized' && row.scheduleEntry" class="table-schedule">
<b> {{ row.scheduleEntry.projectWeek ?? `${row.scheduleEntry.startWeek}${row.scheduleEntry.endWeek}` }} · 星期 {{ row.scheduleEntry.dayOfWeek }} · {{ row.scheduleEntry.startPeriod }}{{ row.scheduleEntry.startPeriod + row.scheduleEntry.periodCount - 1 }} </b>
<small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
</div>
<div v-else-if="row.sessions.length" class="table-schedule">
<b>{{ row.sessions.length }} 个开放场次</b>
<small>{{ row.sessions.map((session: any) => formatSessionTime(session)).join('') }}</small>
<el-popover placement="bottom" :width="520" trigger="click">
<template #reference><el-button size="small" text :icon="User">查看场次与名单</el-button></template>
<div class="student-session-menu">
<div v-for="session in row.sessions" :key="session.id" class="student-session-option">
<div><b>{{ formatSessionTime(session) }}</b><span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span></div>
<el-button size="small" text @click="showParticipants(row, session)">预约名单</el-button>
<el-button v-if="session.status !== 'Cancelled'" size="small" text type="danger" @click="cancelSession(row, session)">{{ row.status === 'Draft' ? '删除' : '取消' }}</el-button>
</div>
</div>
</el-popover>
</div>
<span v-else class="table-muted">尚未安排</span>
</template>
</el-table-column>
<el-table-column label="方式" width="100" align="center">
<template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
</el-table-column>
<el-table-column label="状态" width="88" align="center">
<template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : row.status === 'Closed' ? 'info' : 'warning'">{{ statusLabels[row.status] }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="244" fixed="right" align="right">
<template #default="{ row }">
<template v-if="row.status === 'Draft'">
<el-button size="small" text @click="openEditProject(row)">编辑</el-button>
<el-button size="small" text type="danger" @click="deleteProject(row)">删除</el-button>
<el-button
v-if="project.myBooking?.experimentSessionId === session.id"
size="small"
type="success"
:icon="Check"
disabled
>
已预约
</el-button>
<el-button
v-else
size="small"
type="primary"
plain
:disabled="project.status !== 'Published' || !!project.myBooking || session.remainingCount <= 0"
@click="book(project, session)"
>
预约此场
</el-button>
:disabled="row.arrangementMode === 'Centralized' ? !row.scheduleEntry : !row.sessions.some((item: any) => item.status !== 'Cancelled')"
@click="publishProject(row)"
>发布</el-button>
</template>
<template v-if="!isStudent">
<el-button
size="small"
text
:icon="User"
@click="showParticipants(project, session)"
>
{{ project.arrangementMode === 'Centralized' ? '应到名单' : '预约名单' }}
</el-button>
<el-button
v-if="session.status !== 'Cancelled'"
size="small"
text
type="danger"
@click="cancelSession(project, session)"
>
{{ project.status === 'Draft' ? '删除' : '取消' }}
</el-button>
<el-button v-else-if="row.status === 'Published'" size="small" text @click="closeProject(row)">关闭项目</el-button>
<el-button
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
size="small"
text
type="primary"
@click="openSession(row)"
>安排场次</el-button>
</template>
</el-table-column>
</el-table>
<el-table v-else :data="group.projects" class="project-table student-project-table" size="small">
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div class="student-project-detail">
<p v-if="row.description"><b>项目说明</b>{{ row.description }}</p>
<p v-if="row.requirements"><b>实验要求</b>{{ row.requirements }}</p>
<p><b>开放日期</b>{{ row.startDate }} {{ row.endDate }}</p>
<p><b>任课教师</b>{{ row.teacherNames.join('、') || '待定' }}</p>
</div>
</template>
</el-table-column>
<el-table-column label="实验项目" min-width="210">
<template #default="{ row }">
<div class="table-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small>{{ row.description || modeMeta[row.arrangementMode].note }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="实验安排" min-width="300">
<template #default="{ row }">
<div v-if="row.arrangementMode === 'Centralized' && row.scheduleEntry" class="table-schedule">
<b> {{ row.scheduleEntry.projectWeek ?? `${row.scheduleEntry.startWeek}${row.scheduleEntry.endWeek}` }} · 星期 {{ row.scheduleEntry.dayOfWeek }} · {{ row.scheduleEntry.startPeriod }}{{ row.scheduleEntry.startPeriod + row.scheduleEntry.periodCount - 1 }} </b>
<small>{{ row.scheduleEntry.campusName }} · {{ row.scheduleEntry.buildingName }} {{ row.scheduleEntry.classroomName }}</small>
</div>
<div v-else-if="row.myBooking && selectedSession(row)" class="table-schedule is-booked">
<b>{{ formatSessionTime(selectedSession(row)) }}</b>
<small>{{ selectedSession(row).campusName }} · {{ selectedSession(row).buildingName }} {{ selectedSession(row).classroomName }}</small>
</div>
<div v-else-if="row.sessions.length" class="table-schedule">
<b>{{ row.sessions.length }} 个可选场次</b>
<small>尚未预约请在右侧选择实验时间</small>
</div>
<span v-else class="table-muted">暂无有效实验安排</span>
</template>
</el-table-column>
<el-table-column label="方式" width="100" align="center">
<template #default="{ row }"><el-tag :type="row.arrangementMode === 'Centralized' ? 'primary' : 'success'" effect="plain" size="small">{{ modeMeta[row.arrangementMode].label }}</el-tag></template>
</el-table-column>
<el-table-column label="状态" width="88" align="center">
<template #default="{ row }"><el-tag :type="row.status === 'Published' ? 'success' : 'info'" size="small">{{ statusLabels[row.status] }}</el-tag></template>
</el-table-column>
<el-table-column label="预约操作" width="190" fixed="right" align="right">
<template #default="{ row }">
<template v-if="row.arrangementMode === 'SelfScheduled'">
<el-popover placement="bottom-end" :width="540" trigger="click">
<template #reference>
<el-button size="small" :type="row.myBooking ? 'success' : 'primary'" plain>
{{ row.myBooking ? '查看场次' : '选择场次' }}
</el-button>
</template>
<div class="student-session-menu">
<header><b>{{ row.code }} · {{ row.name }}</b><span>每个实验项目限约一个场次</span></header>
<div v-for="session in row.sessions" :key="session.id" class="student-session-option" :class="{ 'is-selected': row.myBooking?.experimentSessionId === session.id }">
<div>
<b>{{ formatSessionTime(session) }}</b>
<span>{{ session.campusName }} · {{ session.buildingName }} {{ session.classroomName }}</span>
</div>
<el-tag :type="sessionTagType(session)" size="small" effect="plain"> {{ session.remainingCount }} / {{ session.capacity }}</el-tag>
<el-button v-if="row.myBooking?.experimentSessionId === session.id" size="small" type="success" :icon="Check" disabled>已预约</el-button>
<el-button v-else size="small" type="primary" plain :disabled="row.status !== 'Published' || !!row.myBooking || session.remainingCount <= 0" @click="book(row, session)">预约</el-button>
</div>
<el-empty v-if="!row.sessions.length" :image-size="42" description="暂无有效实验场次" />
</div>
</el-popover>
<el-button v-if="row.myBooking && row.status === 'Published'" size="small" text type="danger" @click="cancelBooking(row)">取消</el-button>
</template>
</div>
</article>
</div>
<el-empty
v-else
:image-size="58"
:description="project.arrangementMode === 'Centralized' ? '尚未绑定已发布课表中的实验课' : (project.status === 'Draft' ? '尚未开放场次,安排后才能发布' : '暂无有效实验场次')"
/>
<span v-else class="table-muted">无需预约</span>
</template>
</el-table-column>
</el-table>
</div>
<footer v-if="!isStudent" class="project-actions">
<template v-if="project.status === 'Draft'">
<el-button size="small" @click="openEditProject(project)">编辑项目</el-button>
<el-button size="small" type="danger" plain @click="deleteProject(project)">删除</el-button>
<el-button
size="small"
type="primary"
:disabled="project.arrangementMode === 'Centralized'
? !project.scheduleEntry
: !project.sessions.some((item: any) => item.status !== 'Cancelled')"
@click="publishProject(project)"
>
发布给学生
</el-button>
</template>
<el-button
v-else-if="project.status === 'Published'"
size="small"
@click="closeProject(project)"
>
关闭项目
</el-button>
</footer>
<footer
v-else-if="project.arrangementMode === 'SelfScheduled' && project.myBooking"
class="student-booking-summary"
>
<div>
<Tickets />
<span>
<b>当前预约</b>
{{ formatSessionTime(selectedSession(project)) }}
</span>
</div>
<el-button
v-if="project.status === 'Published'"
size="small"
type="danger"
plain
@click="cancelBooking(project)"
>
取消并重选
</el-button>
</footer>
</article>
</section>
<el-empty
v-if="!loading && !projects.length"
@@ -747,6 +937,19 @@ onMounted(async () => {
</el-empty>
</section>
<el-pagination
v-if="!isStudent && total > pageSize"
class="experiment-pagination"
background
layout="total, sizes, prev, pager, next"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
@current-change="changePage"
@size-change="changePageSize"
/>
<el-dialog
v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
@@ -756,26 +959,24 @@ onMounted(async () => {
<el-form label-position="top" class="experiment-form">
<div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item v-if="projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
<el-select v-model="projectForm.scheduleEntryId" filterable :disabled="!!editingProjectId" placeholder="选择已安排实验室的实验课" @change="onScheduleEntryChange">
<el-option
v-for="entry in options.scheduleEntries"
:key="entry.id"
:label="`${entry.courseCode} · ${entry.courseName} · ${entry.taskNumber} · 星期 ${entry.dayOfWeek} 第 ${entry.startPeriod}${entry.startPeriod + entry.periodCount - 1} 节 · ${entry.campusName} ${entry.buildingName} ${entry.classroomName}`"
:label="formatScheduleEntry(entry)"
:value="entry.id"
/>
</el-select>
<small class="form-help">集中实验复用课表的时间和实验室不会再生成独立实验场次</small>
<small class="form-help">显示当前管理范围内全部已发布已安排实验室的实验课集中实验复用时间和实验室不会再生成独立实验场次</small>
</el-form-item>
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
<el-form-item v-else :label="editingProjectId ? '所属教学任务' : '适用教学(可多选)'" required>
<el-select
v-if="editingProjectId"
v-model="projectForm.teachingTaskIds"
filterable
:multiple="!editingProjectId"
collapse-tags
collapse-tags-tooltip
:disabled="!!editingProjectId"
placeholder="选择同一学期、同一课程的已发布教学任务"
disabled
placeholder="所属教学任务"
@change="onTaskChange"
>
<el-option
@@ -783,23 +984,76 @@ onMounted(async () => {
:key="task.id"
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
:value="task.id"
:disabled="!!selectedTask
&& (task.academicTermId !== selectedTask.academicTermId
|| task.courseId !== selectedTask.courseId)"
/>
</el-select>
<el-checkbox-group
v-else
v-model="projectForm.teachingTaskIds"
class="grouped-task-picker"
@change="onTaskChange"
>
<div class="task-option-filters">
<el-select v-model="offeringCollegeId" clearable filterable placeholder="开课学院" @change="searchTaskOptions">
<el-option v-for="college in options.colleges" :key="college.collegeId" :label="college.collegeName" :value="college.collegeId" />
</el-select>
<el-input v-model="courseKeyword" clearable placeholder="课程名称或代码" @keyup.enter="searchTaskOptions" />
<el-button @click="searchTaskOptions">查询</el-button>
</div>
<section v-for="group in taskGroups" :key="group.key" class="task-picker-course">
<header>
<span>{{ group.termName }}</span>
<b>{{ group.courseCode }} · {{ group.courseName }}</b>
</header>
<div>
<el-checkbox
v-for="task in group.tasks"
:key="task.id"
:value="task.id"
:disabled="projectForm.arrangementMode === 'SelfScheduled' && !!selectedTask
&& (task.academicTermId !== selectedTask.academicTermId
|| task.courseId !== selectedTask.courseId)"
>
<b>{{ task.taskNumber }}</b>
<span>{{ task.classNames.join('、') || task.name }}</span>
</el-checkbox>
</div>
</section>
<el-empty v-if="!taskGroups.length" :image-size="46" description="没有匹配的已发布教学班,请调整筛选条件" />
</el-checkbox-group>
<small v-if="!editingProjectId" class="form-help">
已选 {{ projectForm.teachingTaskIds.length }} 实验编码名称内容和开放日期将一次应用到这些教学任务
<template v-if="projectForm.arrangementMode === 'Centralized'">已选 {{ projectForm.teachingTaskIds.length }} 个教学班系统会为每个教学班的全部已发布实验课生成项目</template>
<template v-else>已选 {{ projectForm.teachingTaskIds.length }} 个教学班只能勾选同一学期同一课程实验编码名称内容和开放日期将一次应用到这些教学任务</template>
</small>
</el-form-item>
<div class="form-grid two">
<el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" />
</el-form-item>
<el-form-item label="项目名称" required>
<el-form-item v-if="editingProjectId" label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
</el-form-item>
</div>
<template v-if="!editingProjectId">
<el-form-item label="项目命名方式" required>
<el-radio-group v-model="projectForm.nameMode">
<el-radio-button value="Sequence">自动生成顺序号</el-radio-button>
<el-radio-button value="InputNames">逐行粘贴名称</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="projectForm.nameMode === 'Sequence'" label="项目名称前缀" required>
<el-input v-model="projectForm.name" maxlength="116" placeholder="如 数据库实验;将生成“数据库实验 01、数据库实验 02”" />
</el-form-item>
<el-form-item v-else label="实验项目名称" required>
<el-input
v-model="projectForm.projectNames"
type="textarea"
:rows="6"
maxlength="120000"
placeholder="每行一个名称,可直接从 Excel 粘贴;系统按实际生成顺序逐行对应"
/>
<small class="form-help">已输入 {{ projectNameLines(projectForm.projectNames).length }} 行有效名称</small>
</el-form-item>
</template>
</div>
<div class="form-section">
@@ -860,6 +1114,23 @@ onMounted(async () => {
</template>
</el-dialog>
<el-dialog v-model="batchRenameDialog" title="批量修改实验项目名称" width="680px">
<el-alert
:title="`已选择 ${batchRenameProjects.length} 个草稿项目。请按下方显示顺序,每行填写一个名称。`"
type="info"
:closable="false"
show-icon
/>
<ol class="batch-rename-preview">
<li v-for="project in batchRenameProjects" :key="project.id">{{ project.code }} · {{ project.name }}</li>
</ol>
<el-input v-model="batchRenameNames" type="textarea" :rows="Math.min(12, Math.max(5, batchRenameProjects.length))" maxlength="12000" placeholder="每行一个实验项目名称" />
<template #footer>
<el-button @click="batchRenameDialog = false">取消</el-button>
<el-button type="primary" @click="saveBatchRename">保存名称</el-button>
</template>
</el-dialog>
<el-dialog
v-model="sessionDialog"
:title="`安排场次 · ${selectedProject?.name ?? ''}`"
@@ -1066,14 +1337,50 @@ onMounted(async () => {
.rail-switch i { width: 30px; height: 7px; border: 2px solid #7794a5; border-radius: 999px; background: #fff; }
.experiment-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 16px; }
.experiment-toolbar .el-select { width: 210px; }
.experiment-toolbar .el-input { width: 172px; }
.result-note { margin-left: auto; color: var(--muted); font-size: 12px; }
.experiment-batch-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; min-height: 46px; margin: -6px 0 16px; padding: 8px 12px; border: 1px solid #cddce3; background: #f7fafb; }
.experiment-batch-bar > span { margin-right: auto; color: #5c7481; font-size: 12px; }
.batch-rename-preview { max-height: 180px; margin: 14px 0; overflow: auto; padding: 10px 12px 10px 32px; border: 1px solid #dce5e9; background: #f8fbfc; color: #526b78; font-size: 12px; line-height: 1.8; }
.experiment-pagination { justify-content: flex-end; margin-top: 4px; }
.project-list { display: grid; gap: 14px; min-height: 160px; }
.project-group { overflow: hidden; border: 1px solid #cddce3; background: #f6fafb; }
.project-group-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 17px; border-left: 5px solid var(--lab-blue); background: #edf4f6; }
.project-group-head > div:first-child { display: grid; gap: 3px; }
.project-group-head h3 { margin: 0; color: var(--lab-ink); font-size: 15px; }
.project-group-head > div:first-child span { color: #68808d; font: 700 10px/1 Consolas, monospace; letter-spacing: .06em; }
.project-group-task { display: grid; justify-items: end; gap: 3px; color: #5c7481; font-size: 11px; text-align: right; }
.project-group-task b { color: #365364; font-size: 12px; }
.project-group-projects { display: grid; gap: 0; padding: 0; }
.project-table { --el-table-border-color: #dce5ea; --el-table-header-bg-color: #f7fafb; --el-table-row-hover-bg-color: #f6fafb; width: 100%; }
.project-table :deep(th.el-table__cell) { padding: 8px 0; color: #617986; font-size: 11px; font-weight: 650; }
.project-table :deep(td.el-table__cell) { padding: 9px 0; vertical-align: top; }
.student-project-table :deep(td.el-table__cell) { vertical-align: middle; }
.table-project-name, .table-schedule { display: grid; gap: 4px; min-width: 0; }
.table-project-name b { color: var(--lab-ink); font-size: 13px; }
.table-project-name small, .table-schedule small { overflow: hidden; color: #667f8d; font-size: 11px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.table-schedule b { color: #365364; font-size: 12px; font-weight: 600; }
.table-schedule.is-booked b { color: var(--lab-teal); }
.table-muted { color: #8b9aa4; font-size: 12px; }
.student-project-detail { display: grid; grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 7px 20px; padding: 11px 18px 13px; background: #f7fafb; }
.student-project-detail p { margin: 0; display: grid; grid-template-columns: 70px 1fr; gap: 8px; color: #5f7682; font-size: 11px; line-height: 1.55; }
.student-project-detail b { color: #304d5d; }
.student-session-menu { display: grid; gap: 7px; }
.student-session-menu > header { display: flex; justify-content: space-between; gap: 12px; padding-bottom: 8px; border-bottom: 1px solid #dfe7ea; }
.student-session-menu > header b { color: var(--lab-ink); font-size: 12px; }
.student-session-menu > header span { color: #71858e; font-size: 10px; }
.student-session-option { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 9px; padding: 8px 9px; border: 1px solid #e0e7ea; background: #fff; }
.student-session-option.is-selected { border-color: #79afa5; box-shadow: inset 3px 0 0 var(--lab-teal); background: #f3faf8; }
.student-session-option > div { display: grid; gap: 2px; min-width: 0; }
.student-session-option > div b { color: #304d5d; font-size: 11px; }
.student-session-option > div span { overflow: hidden; color: #6a808b; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.project-card {
overflow: hidden;
border: 1px solid #dce5ea;
border-top: 4px solid var(--lab-blue);
border: 0;
border-top: 1px solid #dce5ea;
border-left: 4px solid var(--lab-blue);
background: #fff;
box-shadow: 0 7px 22px rgb(35 67 85 / 5%);
box-shadow: none;
}
.project-card.is-flexible { border-top-color: var(--lab-teal); }
.project-card.is-closed { opacity: .82; }
@@ -1140,6 +1447,16 @@ onMounted(async () => {
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
.experiment-form { display: grid; gap: 13px; }
.grouped-task-picker { display: grid; gap: 10px; max-height: 310px; overflow: auto; padding: 10px; border: 1px solid #d8e3e8; background: #fff; }
.task-option-filters { display: grid; grid-template-columns: minmax(150px, 1fr) minmax(180px, 1.2fr) auto; gap: 8px; }
.task-picker-course { overflow: hidden; border: 1px solid #e0e8eb; }
.task-picker-course header { display: flex; align-items: baseline; gap: 8px; padding: 8px 10px; background: #f3f7f8; }
.task-picker-course header span { color: #738994; font: 700 10px/1 Consolas, monospace; }
.task-picker-course header b { color: #314f60; font-size: 12px; }
.task-picker-course > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 2px 12px; padding: 8px 10px; }
.task-picker-course :deep(.el-checkbox) { height: auto; margin-right: 0; white-space: normal; }
.task-picker-course :deep(.el-checkbox__label) { display: inline-flex; gap: 6px; min-width: 0; color: #536b78; font-size: 12px; }
.task-picker-course :deep(.el-checkbox__label b) { flex: 0 0 auto; color: #284757; }
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
@@ -1183,6 +1500,7 @@ onMounted(async () => {
.session-ticket { grid-template-columns: 54px minmax(0, 1fr); }
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
.project-actions, .student-booking-summary { padding-inline: 14px; }
.student-project-detail { grid-template-columns: 1fr; }
.mode-choice { grid-template-columns: 1fr; }
.intro-actions { flex-wrap: wrap; justify-content: flex-end; }
}
+78 -2
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Download, Refresh, Search } from '@element-plus/icons-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Download, Refresh, Search, Setting } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
import { academicTermLabel, defaultAcademicTermId } from '../utils/academicTerms'
import { useAuthStore } from '../stores/auth'
interface TeachingClassItem {
gradeSheetId: string
@@ -25,6 +26,8 @@ interface TeachingClassItem {
}
const terms = ref<any[]>([])
const auth = useAuthStore()
const canManageSchedule = computed(() => auth.user?.roles.some(role => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false)
const classes = ref<TeachingClassItem[]>([])
const selected = ref<TeachingClassItem>()
const report = ref<any>()
@@ -37,6 +40,16 @@ const loading = ref(false)
const reportLoading = ref(false)
const exporting = ref(false)
const historyMetric = ref<'average' | 'passRate' | 'excellentRate'>('average')
const scheduleDialogVisible = ref(false)
const scheduleLoading = ref(false)
const scheduleSaving = ref(false)
const schedule = reactive({
isEnabled: true,
intervalMinutes: 5,
batchSize: 100,
lastRunAt: null as string | null,
nextRunAt: null as string | null,
})
const distributionElement = ref<HTMLElement>()
const peerAverageElement = ref<HTMLElement>()
@@ -134,6 +147,40 @@ async function refreshStatistics() {
}
}
async function openScheduleSettings() {
scheduleDialogVisible.value = true
scheduleLoading.value = true
try {
const data = (await http.get('/grade-analytics/refresh-schedule')).data
schedule.isEnabled = data.isEnabled
schedule.intervalMinutes = Math.max(1, Math.round(data.intervalSeconds / 60))
schedule.batchSize = data.batchSize
schedule.lastRunAt = data.lastRunAt
schedule.nextRunAt = data.nextRunAt
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
scheduleLoading.value = false
}
}
async function saveScheduleSettings() {
scheduleSaving.value = true
try {
await http.put('/grade-analytics/refresh-schedule', {
isEnabled: schedule.isEnabled,
intervalSeconds: schedule.intervalMinutes * 60,
batchSize: schedule.batchSize,
})
ElMessage.success('成绩统计定时刷新设置已保存')
scheduleDialogVisible.value = false
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
scheduleSaving.value = false
}
}
async function exportWordReport() {
if (!selected.value) return
exporting.value = true
@@ -307,6 +354,7 @@ onBeforeUnmount(() => {
<p>对比当前教学班同课程教学班学生来源范围与历年成绩统计仅使用已正式发布成绩</p>
</div>
<div class="intro-actions">
<el-button v-if="canManageSchedule" :icon="Setting" @click="openScheduleSettings">定时刷新设置</el-button>
<el-button
type="primary"
:icon="Download"
@@ -318,6 +366,32 @@ onBeforeUnmount(() => {
</div>
</section>
<el-dialog v-model="scheduleDialogVisible" title="成绩统计定时刷新" width="520px">
<el-form v-loading="scheduleLoading" label-width="130px">
<el-form-item label="启用定时刷新">
<el-switch v-model="schedule.isEnabled" />
</el-form-item>
<el-form-item label="刷新间隔">
<el-input-number v-model="schedule.intervalMinutes" :min="1" :max="1440" :disabled="!schedule.isEnabled" />
<span class="schedule-unit">分钟</span>
</el-form-item>
<el-form-item label="单次处理上限">
<el-input-number v-model="schedule.batchSize" :min="1" :max="5000" :step="50" :disabled="!schedule.isEnabled" />
<span class="schedule-unit">个课程学期</span>
</el-form-item>
<el-form-item label="运行状态">
<div class="schedule-status">
<span>上次扫描{{ schedule.lastRunAt ? new Date(schedule.lastRunAt).toLocaleString('zh-CN') : '尚未运行' }}</span>
<span v-if="schedule.isEnabled">下次扫描{{ schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleString('zh-CN') : '启用后将尽快运行' }}</span>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scheduleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="scheduleSaving" @click="saveScheduleSettings">保存设置</el-button>
</template>
</el-dialog>
<section class="filter-bar">
<el-select v-model="termId" clearable placeholder="全部学期" style="width: 240px" @change="loadClasses(true)">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" />
@@ -461,6 +535,8 @@ onBeforeUnmount(() => {
.chart-card h4 { margin: 5px 0 0; color: var(--ink); font-size: 16px; }
.chart-card header p, .chart-caption { min-width: 0; margin: 0; color: #667085; font-size: 11px; text-align: right; }
.chart-caption { margin-top: 9px; text-align: left; }
.schedule-unit { margin-left: 10px; color: #667085; font-size: 12px; }
.schedule-status { display: grid; gap: 3px; color: #667085; font-size: 12px; }
.chart { height: 310px; margin-top: 10px; }
.chart-large { height: 360px; }
@media (max-width: 1180px) {
+193 -21
View File
@@ -55,9 +55,20 @@ const weekdays = [
{ value: 7, label: '星期日' },
]
const experimentVenueNatures = [
{ value: 2, label: '实验室' }, { value: 4, label: '实室' },
{ value: 8, label: '计算机机房' }, { value: 16, label: '语音室' },
{ value: 1, label: '普通教室' }, { value: 2, label: '实室' },
{ value: 4, label: '实训室' }, { value: 8, label: '计算机机房' },
{ value: 16, label: '语音室' }, { value: 32, label: '体育场地' },
{ value: 64, label: '艺术场地' },
]
const venueNatureValue = (value: unknown) => {
if (typeof value === 'number') return value
if (typeof value !== 'string') return 0
const names: Record<string, number> = {
GeneralClassroom: 1, Laboratory: 2, TrainingRoom: 4, ComputerLab: 8,
LanguageLab: 16, SportsVenue: 32, ArtsVenue: 64,
}
return value.split(',').reduce((sum, name) => sum | (names[name.trim()] ?? 0), 0)
}
const periods = computed(() => {
const configured = timeSlots.value
.filter((item) => item.isEnabled)
@@ -129,17 +140,21 @@ const publishStatusText = computed(() => {
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
const isExperimentRoom = (room: any) =>
(Number(room.teachingVenueNature) & (2 | 4 | 8 | 16)) !== 0
const entryClassrooms = computed(() =>
classrooms.value.filter((room) =>
(!selectedTaskConstraint.value?.requiredCampusId
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredCampusId
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
(!selectedTaskConstraint.value?.requiredBuildingId
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.requiredBuildingId
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
(!selectedTaskConstraint.value?.allowedClassroomIds?.length
(entryForm.kind === 'Experiment' || !selectedTaskConstraint.value?.allowedClassroomIds?.length
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
(entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredCampusId
|| room.campusId === selectedTaskConstraint.value.experimentRequiredCampusId) &&
(entryForm.kind !== 'Experiment' || !selectedTaskConstraint.value?.experimentRequiredBuildingId
|| room.buildingId === selectedTaskConstraint.value.experimentRequiredBuildingId) &&
(entryForm.kind !== 'Experiment' ||
!selectedTaskConstraint.value?.allowedExperimentClassroomIds?.length ||
selectedTaskConstraint.value.allowedExperimentClassroomIds.includes(room.id)),
),
)
const entryWeekdays = computed(() => {
@@ -192,6 +207,22 @@ const filteredClassrooms = computed(() =>
(!constraintForm.requiredBuildingId || item.buildingId === constraintForm.requiredBuildingId),
),
)
const filteredExperimentBuildings = computed(() =>
constraintForm.experimentRequiredCampusId
? buildings.value.filter((item) => item.campusId === constraintForm.experimentRequiredCampusId)
: buildings.value,
)
const filteredExperimentClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintForm.experimentRequiredCampusId ||
item.campusId === constraintForm.experimentRequiredCampusId) &&
(!constraintForm.experimentRequiredBuildingId ||
item.buildingId === constraintForm.experimentRequiredBuildingId) &&
(!(constraintForm.allowedExperimentVenueNatures ?? []).length ||
(venueNatureValue(item.teachingVenueNature) & (constraintForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0)) !== 0),
),
)
const batchFilteredBuildings = computed(() =>
constraintBatchForm.requiredCampusId
? buildings.value.filter((item) => item.campusId === constraintBatchForm.requiredCampusId)
@@ -205,6 +236,23 @@ const batchFilteredClassrooms = computed(() =>
item.buildingId === constraintBatchForm.requiredBuildingId),
),
)
const batchFilteredExperimentBuildings = computed(() =>
constraintBatchForm.experimentRequiredCampusId
? buildings.value.filter((item) => item.campusId === constraintBatchForm.experimentRequiredCampusId)
: buildings.value,
)
const batchFilteredExperimentClassrooms = computed(() =>
classrooms.value.filter((item) =>
(!constraintBatchForm.experimentRequiredCampusId ||
item.campusId === constraintBatchForm.experimentRequiredCampusId) &&
(!constraintBatchForm.experimentRequiredBuildingId ||
item.buildingId === constraintBatchForm.experimentRequiredBuildingId) &&
(!(constraintBatchForm.allowedExperimentVenueNatures ?? []).length ||
(venueNatureValue(item.teachingVenueNature) &
(constraintBatchForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0)) !== 0),
),
)
const filteredEntries = computed(() => {
const text = keyword.value.trim().toLowerCase()
if (!text) return selected.value?.entries ?? []
@@ -293,11 +341,15 @@ function openConstraint(item: any) {
Object.assign(constraintForm, {
teachingTaskId: item.id,
title: `${item.taskNumber} · ${item.name}`,
coursePracticeHours: item.coursePracticeHours,
schedulingMode: item.schedulingMode,
requiresClassroom: item.requiresClassroom,
requiredCampusId: item.requiredCampusId,
requiredBuildingId: item.requiredBuildingId,
experimentRequiredCampusId: item.experimentRequiredCampusId,
experimentRequiredBuildingId: item.experimentRequiredBuildingId,
allowedClassroomIds: [...item.allowedClassroomIds],
allowedExperimentClassroomIds: [...item.allowedExperimentClassroomIds],
allowedExperimentVenueNatures: experimentVenueNatures
.filter((nature) => (Number(item.allowedExperimentVenueNatures) & nature.value) !== 0)
.map((nature) => nature.value),
@@ -317,7 +369,10 @@ async function saveConstraint() {
requiresClassroom: constraintForm.requiresClassroom,
requiredCampusId: constraintForm.requiredCampusId || null,
requiredBuildingId: constraintForm.requiredBuildingId || null,
experimentRequiredCampusId: constraintForm.experimentRequiredCampusId || null,
experimentRequiredBuildingId: constraintForm.experimentRequiredBuildingId || null,
allowedClassroomIds: constraintForm.allowedClassroomIds ?? [],
allowedExperimentClassroomIds: constraintForm.allowedExperimentClassroomIds ?? [],
allowedExperimentVenueNatures: (constraintForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0),
allowedDayOfWeeks: constraintForm.allowedDayOfWeeks ?? [],
@@ -358,6 +413,11 @@ function openConstraintBatch() {
requiredCampusId: undefined,
requiredBuildingId: undefined,
allowedClassroomIds: [],
updateExperimentClassroomScope: false,
experimentRequiredCampusId: undefined,
experimentRequiredBuildingId: undefined,
allowedExperimentVenueNatures: [],
allowedExperimentClassroomIds: [],
updateDays: false,
allowedDayOfWeeks: [1, 2, 3, 4, 5],
updatePeriodRange: false,
@@ -369,8 +429,9 @@ function openConstraintBatch() {
async function saveConstraintBatch() {
if (!constraintBatchForm.updateSchedulingMode &&
!constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.updateClassroomScope &&
!constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.updateClassroomScope &&
!constraintBatchForm.updateExperimentClassroomScope &&
!constraintBatchForm.updateDays &&
!constraintBatchForm.updatePeriodRange) {
ElMessage.warning('请至少勾选一项需要批量修改的设置。')
@@ -390,6 +451,9 @@ async function saveConstraintBatch() {
!(constraintBatchForm.updateRequiresClassroom &&
!constraintBatchForm.requiresClassroom) &&
constraintBatchForm.updateClassroomScope
const updateExperimentClassroomScope = !flexible &&
!(constraintBatchForm.updateRequiresClassroom && !constraintBatchForm.requiresClassroom) &&
constraintBatchForm.updateExperimentClassroomScope
const { data } = await http.put('/schedules/constraints/batch', {
academicTermId: termId.value,
teachingTaskIds: targets.map((item) => item.id),
@@ -409,6 +473,20 @@ async function saveConstraintBatch() {
allowedClassroomIds: updateClassroomScope
? constraintBatchForm.allowedClassroomIds
: null,
updateExperimentClassroomScope,
experimentRequiredCampusId: updateExperimentClassroomScope
? constraintBatchForm.experimentRequiredCampusId || null
: null,
experimentRequiredBuildingId: updateExperimentClassroomScope
? constraintBatchForm.experimentRequiredBuildingId || null
: null,
allowedExperimentVenueNatures: updateExperimentClassroomScope
? (constraintBatchForm.allowedExperimentVenueNatures ?? [])
.reduce((value: number, nature: number) => value | nature, 0)
: null,
allowedExperimentClassroomIds: updateExperimentClassroomScope
? constraintBatchForm.allowedExperimentClassroomIds
: null,
allowedDayOfWeeks: !flexible && constraintBatchForm.updateDays
? constraintBatchForm.allowedDayOfWeeks
: null,
@@ -708,10 +786,13 @@ function changeEntryTask() {
}
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
if (room && (
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
(entryForm.kind === 'Experiment' && !isExperimentRoom(room))
(entryForm.kind !== 'Experiment' && task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
(entryForm.kind !== 'Experiment' && task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
(entryForm.kind !== 'Experiment' && task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
(entryForm.kind === 'Experiment' && task.experimentRequiredCampusId && room.campusId !== task.experimentRequiredCampusId) ||
(entryForm.kind === 'Experiment' && task.experimentRequiredBuildingId && room.buildingId !== task.experimentRequiredBuildingId) ||
(entryForm.kind === 'Experiment' && task.allowedExperimentClassroomIds?.length &&
!task.allowedExperimentClassroomIds.includes(room.id))
)) {
entryForm.classroomId = null
}
@@ -719,11 +800,23 @@ function changeEntryTask() {
function changeEntryKind() {
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
const task = selectedTaskConstraint.value
if (entryForm.kind === 'Experiment' && room && task?.allowedExperimentClassroomIds?.length &&
!task.allowedExperimentClassroomIds.includes(room.id)) {
entryForm.classroomId = null
}
}
async function preflightSchedule() {
try {
const { data } = await http.get(`/schedules/plans/${selected.value.id}/preflight`)
const message = data.unscheduledTasks
? `尚有 ${data.unscheduledTasks} 个教学班未安排:\n${data.messages.join('\n')}`
: '当前草稿已覆盖全部需要排课的教学班。'
await ElMessageBox.alert(message, `排课前检查 · 已覆盖 ${data.scheduledTasks}/${data.totalTasks}`, { confirmButtonText: '知道了' })
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function saveEntry() {
if (!entryForm.teachingTaskId ||
((entryForm.kind === 'Experiment' ||
@@ -854,6 +947,13 @@ onBeforeUnmount(() => {
>
复制调整
</el-button>
<el-button
v-if="isDraft"
:disabled="scheduleJobLoading"
@click="preflightSchedule"
>
排课前检查
</el-button>
<el-button
v-if="isDraft"
type="warning"
@@ -1047,7 +1147,7 @@ onBeforeUnmount(() => {
/>
<el-form-item
v-else
:label="entryForm.kind === 'Experiment' ? '实验室 / 实训室 / 机房' : '教室'"
:label="entryForm.kind === 'Experiment' ? '教学场地' : '教室'"
required
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
>
@@ -1227,12 +1327,38 @@ onBeforeUnmount(() => {
/>
</el-select>
</el-form-item>
<el-form-item label="实验课允许的场地性质">
<el-form-item label="实验课允许的场地性质">
<el-checkbox-group v-model="constraintForm.allowedExperimentVenueNatures">
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
</el-checkbox-group>
<small class="field-hint">仅约束实验课不勾选时可使用全部实验教学场地</small>
</el-form-item>
<small class="field-hint">仅约束实验课不勾选时可使用全部实验教学场地</small>
</el-form-item>
<section v-if="constraintForm.coursePracticeHours" class="experiment-classroom-scope">
<div class="experiment-classroom-scope__title">实验课指定可用场地</div>
<div class="form-grid">
<el-form-item label="实验课限定校区">
<el-select v-model="constraintForm.experimentRequiredCampusId" clearable @change="constraintForm.experimentRequiredBuildingId = undefined; constraintForm.allowedExperimentClassroomIds = []">
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="实验课限定教学楼">
<el-select v-model="constraintForm.experimentRequiredBuildingId" clearable @change="constraintForm.allowedExperimentClassroomIds = []">
<el-option v-for="item in filteredExperimentBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="实验课指定可用场地">
<el-select v-model="constraintForm.allowedExperimentClassroomIds" multiple filterable collapse-tags>
<el-option
v-for="item in filteredExperimentClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.roomType}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
<small class="field-hint">可在上方场地性质范围内指定实验室不选择时按场地性质自动筛选</small>
</el-form-item>
</section>
</template>
<el-form-item label="允许上课日">
<el-checkbox-group v-model="constraintForm.allowedDayOfWeeks">
@@ -1291,7 +1417,7 @@ onBeforeUnmount(() => {
<el-checkbox v-model="constraintBatchForm.updateClassroomScope">
批量指定教室范围
</el-checkbox>
<div v-if="constraintBatchForm.updateClassroomScope" class="batch-classroom-scope">
<div v-if="constraintBatchForm.updateClassroomScope" class="batch-classroom-scope">
<el-alert
title="将统一设置为需要教室;校区、教学楼和教室均不选择时,表示清除原有限定并允许系统自动分配。"
type="warning"
@@ -1317,7 +1443,7 @@ onBeforeUnmount(() => {
<el-option v-for="item in batchFilteredBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
</div>
<el-form-item label="统一指定可用教室">
<el-select
v-model="constraintBatchForm.allowedClassroomIds"
@@ -1337,6 +1463,52 @@ onBeforeUnmount(() => {
</el-form-item>
</div>
</template>
<el-checkbox v-model="constraintBatchForm.updateExperimentClassroomScope">
批量指定实验课场地
</el-checkbox>
<div v-if="constraintBatchForm.updateExperimentClassroomScope" class="batch-classroom-scope">
<el-alert
title="仅作用于含实验学时的教学任务;不选择具体场地时,按场地性质自动分配。"
type="warning"
:closable="false"
show-icon
/>
<el-form-item label="统一实验课允许的场地性质">
<el-checkbox-group v-model="constraintBatchForm.allowedExperimentVenueNatures">
<el-checkbox v-for="nature in experimentVenueNatures" :key="nature.value" :value="nature.value">{{ nature.label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<div class="form-grid">
<el-form-item label="统一实验课限定校区">
<el-select
v-model="constraintBatchForm.experimentRequiredCampusId"
clearable
@change="constraintBatchForm.experimentRequiredBuildingId = undefined; constraintBatchForm.allowedExperimentClassroomIds = []"
>
<el-option v-for="item in campuses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="统一实验课限定教学楼">
<el-select
v-model="constraintBatchForm.experimentRequiredBuildingId"
clearable
@change="constraintBatchForm.allowedExperimentClassroomIds = []"
>
<el-option v-for="item in batchFilteredExperimentBuildings" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="统一实验课指定可用场地">
<el-select v-model="constraintBatchForm.allowedExperimentClassroomIds" multiple filterable collapse-tags placeholder="不选择则允许符合性质的任意实验场地">
<el-option
v-for="item in batchFilteredExperimentClassrooms"
:key="item.id"
:label="`${item.buildingName} / ${item.name}${item.roomType}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<el-checkbox v-model="constraintBatchForm.updateDays">修改允许上课日</el-checkbox>
<el-form-item v-if="constraintBatchForm.updateDays" label="统一允许上课日">
<el-checkbox-group v-model="constraintBatchForm.allowedDayOfWeeks">
+139 -2
View File
@@ -70,6 +70,27 @@ interface GradeItem {
publishedAt?: string
}
interface DashboardGreeting {
title: string
subtitle: string
label: string
narrative: string
insights: Array<{
label: string
value: string
hint: string
tone: 'calm' | 'positive' | 'attention'
}>
}
interface WarningItem {
id: string
type: number
status: number
detail: string
createdAt: string
}
const router = useRouter()
const auth = useAuthStore()
const loading = ref(true)
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
const unreadCount = ref(0)
const exams = ref<ExamItem[]>([])
const grades = ref<GradeItem[]>([])
const dashboardGreeting = ref<DashboardGreeting | null>(null)
const warnings = ref<WarningItem[]>([])
const graduation = ref<any>(null)
const now = ref(new Date())
const categoryLabels: Record<string, string> = {
@@ -159,6 +183,11 @@ const recentGrades = computed(() =>
.slice(0, 4),
)
const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1))
const radarSummary = computed(() => activeWarnings.value.length
? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。`
: '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。')
function parseDateOnly(value?: string) {
if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
@@ -281,6 +310,9 @@ async function loadOverview() {
http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
http.get('/exams/my-schedule'),
http.get('/grades/student/transcript'),
http.get<DashboardGreeting>('/dashboard/greeting'),
http.get<WarningItem[]>('/warnings/my-warnings'),
http.get('/student/academic-planning'),
])
if (results[0].status === 'fulfilled') {
@@ -304,6 +336,15 @@ async function loadOverview() {
} else {
failedSections.value.push('考试成绩')
}
if (results[4].status === 'fulfilled') {
dashboardGreeting.value = results[4].value.data
}
if (results[5].status === 'fulfilled') {
warnings.value = results[5].value.data
} else {
failedSections.value.push('学业风险雷达')
}
if (results[6].status === 'fulfilled') graduation.value = results[6].value.data
loading.value = false
}
@@ -315,8 +356,8 @@ onMounted(loadOverview)
<section class="student-overview-hero">
<div class="student-hero-copy">
<span class="section-kicker">MY ACADEMIC DAY</span>
<h2>{{ greeting }}{{ auth.user?.displayName ?? '同学' }}</h2>
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p>
<h2>{{ dashboardGreeting?.title ?? `${greeting}${auth.user?.displayName ?? '同学'}` }}</h2>
<p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
</div>
<div class="today-status">
<span>今日课程</span>
@@ -325,6 +366,39 @@ onMounted(loadOverview)
</div>
</section>
<section v-if="dashboardGreeting?.insights.length" class="student-greeting-insights" :aria-label="dashboardGreeting.label">
<span>{{ dashboardGreeting.label }}</span>
<article v-for="insight in dashboardGreeting.insights" :key="insight.label" :class="insight.tone">
<small>{{ insight.label }}</small>
<strong>{{ insight.value }}</strong>
<em>{{ insight.hint }}</em>
</article>
</section>
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
<header>
<div>
<span class="panel-kicker">ACADEMIC RADAR</span>
<h3>学业风险雷达</h3>
</div>
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
</header>
<p>{{ radarSummary }}</p>
<div v-if="activeWarnings.length" class="risk-list">
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
<span>{{ categoryLabels.Warning }}</span>
<strong>{{ warning.detail }}</strong>
<i>去处理 </i>
</button>
</div>
</section>
<section v-if="graduation?.baseline" class="graduation-nav">
<div><span class="panel-kicker">GRADUATION NAVIGATION</span><h3>毕业导航</h3><p>已完成 {{ graduation.baseline.planCompletedCredits ?? 0 }} 学分距离培养方案要求还差 {{ graduation.baseline.creditGap ?? 0 }} 学分</p></div>
<button type="button" @click="router.push('/academic-planning')">查看毕业航线 <el-icon><ArrowRight /></el-icon></button>
</section>
<el-alert
v-if="failedSections.length"
type="warning"
@@ -539,6 +613,63 @@ onMounted(loadOverview)
font-size: 13px;
}
.student-greeting-insights {
padding: 15px 21px;
display: grid;
grid-template-columns: 105px repeat(3, minmax(0, 1fr));
gap: 12px;
border: 1px solid #dce6eb;
background: #f7faf9;
}
.student-greeting-insights > span {
align-self: center;
color: var(--teal);
font: 700 10px/1.5 Consolas, monospace;
letter-spacing: .11em;
}
.student-greeting-insights article {
min-width: 0;
padding-left: 12px;
display: grid;
gap: 3px;
border-left: 2px solid #a9bcc6;
}
.student-greeting-insights article.positive { border-color: var(--teal); }
.student-greeting-insights article.attention { border-color: #c4812a; }
.student-greeting-insights small { color: #667488; font-size: 10px; }
.student-greeting-insights strong { color: var(--ink); font-size: 19px; }
.student-greeting-insights em { overflow: hidden; color: var(--muted); font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.student-greeting-narrative {
margin: -5px 0 0;
padding: 0 3px;
color: #526078;
font-size: 12px;
line-height: 1.7;
}
.student-risk-radar {
padding: 20px 22px;
border: 1px solid #dce6eb;
background: #fbfdfd;
}
.graduation-nav{padding:19px 22px;display:flex;justify-content:space-between;align-items:center;gap:18px;border:1px solid #dce6eb;background:#f7faf9}.graduation-nav h3{margin:7px 0;color:var(--ink);font-size:17px}.graduation-nav p{margin:0;color:#59677a;font-size:12px;line-height:1.6}.graduation-nav button{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--indigo);font-size:12px;white-space:nowrap}
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
.student-risk-radar header button:hover { color: var(--indigo); }
.student-risk-radar > p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; }
.risk-list { margin-top: 13px; display: grid; }
.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; }
.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; }
.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; }
.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; }
.today-status {
min-width: 180px;
margin-left: auto;
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
}
@media (max-width: 980px) {
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
.student-overview-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
.student-risk-radar { padding: 18px; }
.graduation-nav{padding:18px;align-items:flex-start;flex-direction:column}
.risk-list button { grid-template-columns: 1fr auto; }
.risk-list span { grid-column: 1 / -1; }
.student-overview {
gap: 12px;
}
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ArrowRight, Calendar, DocumentChecked, Reading } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import http from '../api/http'
interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } }
const router = useRouter()
const greeting = ref<Greeting | null>(null)
const sheets = ref<Sheet[]>([])
const loading = ref(true)
const failed = ref(false)
const statusLabel: Record<string, string> = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' }
const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0
const pendingSheets = computed(() => sheets.value.filter(x => x.sheet?.status === 'Draft' || x.sheet?.status === 'Returned'))
async function load() {
loading.value = true; failed.value = false
try {
const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard')
greeting.value = dashboard.data.greeting
const result = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } })
sheets.value = result.data.items
} catch { failed.value = true } finally { loading.value = false }
}
onMounted(load)
</script>
<template>
<div v-loading="loading" class="teacher-cockpit">
<el-result v-if="failed" icon="warning" title="教学驾驶舱加载失败" sub-title="请检查服务连接后重新加载。"><template #extra><el-button type="primary" @click="load">重新加载</el-button></template></el-result>
<template v-else-if="greeting">
<section class="cockpit-hero">
<span>TEACHING COCKPIT</span><h2>{{ greeting.title }}</h2><p>{{ greeting.subtitle }}</p><small>{{ greeting.narrative }}</small>
</section>
<section class="cockpit-metrics"><article v-for="item in greeting.insights" :key="item.label" :class="item.tone"><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.hint }}</small></article></section>
<section class="cockpit-grid">
<article class="cockpit-panel">
<header><div><span><el-icon><DocumentChecked /></el-icon> GRADE PROGRESS</span><h3>成绩登记进度</h3></div><button @click="router.push('/grades')">成绩管理 <el-icon><ArrowRight /></el-icon></button></header>
<div v-if="pendingSheets.length" class="sheet-list"><button v-for="item in pendingSheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><div><b>{{ item.courseName }}</b><small>{{ item.classNames.join('、') || item.taskName }} · {{ statusLabel[item.sheet?.status ?? ''] ?? '待登记' }}</small></div><strong>{{ progress(item.sheet) }}%</strong></button></div>
<div v-else class="cockpit-empty"><el-icon><DocumentChecked /></el-icon><b>当前没有待提交成绩</b><span>成绩登记册会在这里按优先级显示</span></div>
</article>
<article class="cockpit-panel">
<header><div><span><el-icon><Reading /></el-icon> TEACHING CLASSES</span><h3>本学期教学班</h3></div><button @click="router.push('/teaching-tasks')">教学任务 <el-icon><ArrowRight /></el-icon></button></header>
<div v-if="sheets.length" class="class-list"><button v-for="item in sheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><b>{{ item.courseName }}</b><span>{{ item.classNames.join('、') || item.taskName }}</span><small>{{ item.sheet ? `${item.sheet.studentCount} 人 · 已完成 ${item.sheet.completedCount}` : '尚未建立成绩登记册' }}</small></button></div>
<div v-else class="cockpit-empty"><el-icon><Calendar /></el-icon><b>本学期暂未分配教学班</b><span>教学任务发布后会自动汇总到这里</span></div>
</article>
</section>
</template>
</div>
</template>
<style scoped>
.teacher-cockpit{display:grid;gap:18px}.cockpit-hero{padding:31px 35px;color:#fff;background:linear-gradient(120deg,#17284f,#244276 62%,#08726d);}.cockpit-hero>span,.cockpit-panel header>div>span{color:#71d9ca;font:700 10px/1 Consolas,monospace;letter-spacing:.12em}.cockpit-hero h2{margin:13px 0 8px;font:700 clamp(27px,3vw,38px)/1.2 "STZhongsong","Songti SC",serif}.cockpit-hero p{margin:0;color:#d1daf0}.cockpit-hero small{display:block;margin-top:15px;color:#aebee0;font-size:12px}.cockpit-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cockpit-metrics article{padding:17px;border-left:3px solid #a9bcc6;background:#f7faf9;display:grid;gap:4px}.cockpit-metrics .positive{border-color:#098174}.cockpit-metrics .attention{border-color:#ce8b2c}.cockpit-metrics span,.cockpit-metrics small{color:#657488;font-size:11px}.cockpit-metrics strong{color:#17284f;font-size:23px}.cockpit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.cockpit-panel{padding:23px 26px;border:1px solid #e2e7ec;background:#fff}.cockpit-panel header{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #e9edf0}.cockpit-panel h3{margin:8px 0 16px;color:#263648;font-size:18px}.cockpit-panel header button{border:0;background:transparent;color:#526078;font-size:12px;white-space:nowrap}.cockpit-panel button{cursor:pointer}.sheet-list button,.class-list button{width:100%;padding:14px 0;display:flex;justify-content:space-between;gap:12px;text-align:left;border:0;border-bottom:1px solid #edf0f3;background:transparent}.sheet-list b,.class-list b{display:block;color:#2e3c4c;font-size:14px}.sheet-list small,.class-list span,.class-list small{display:block;margin-top:5px;color:#748093;font-size:11px}.sheet-list strong{align-self:center;color:#0b8175;font:700 18px Consolas,monospace}.cockpit-empty{min-height:170px;display:grid;place-content:center;justify-items:center;color:#8c96a5;gap:8px;text-align:center;font-size:12px}.cockpit-empty .el-icon{font-size:26px}.cockpit-empty b{color:#5e6c7e}@media(max-width:760px){.cockpit-hero{padding:25px 21px}.cockpit-metrics,.cockpit-grid{grid-template-columns:1fr}.cockpit-panel{padding:20px 18px}}
</style>
+8 -7
View File
@@ -6,7 +6,7 @@ import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
const isWarningManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
const isCounselor = computed(() => auth.user?.roles.includes('Counselor') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)))
const isStudent = computed(() => auth.user?.roles.includes('Student') && !auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(r)))
@@ -43,11 +43,11 @@ const ruleRows = reactive([
async function load() {
loading.value = true
try {
if (isSuperAdmin.value && termId.value) {
if (isWarningManager.value && termId.value) {
const serverRules = (await http.get('/warnings/rules', { params: { academicTermId: termId.value } })).data
// Merge server values into reactive rows
for (const row of ruleRows) {
const sr = serverRules.find((r: any) => r.type === row.type)
const sr = serverRules.find((r: any) => Number(r.type) === row.type)
if (sr) {
row.threshold = sr.threshold
row.isEnabled = sr.isEnabled
@@ -62,7 +62,7 @@ async function load() {
}
}
}
if (isSuperAdmin.value || isCounselor.value)
if (isWarningManager.value || isCounselor.value)
records.value = (await http.get('/warnings/records', { params: { academicTermId: termId.value || undefined, type: filterType.value || undefined } })).data
if (isStudent.value) myWarnings.value = (await http.get('/warnings/my-warnings')).data
} catch (e) { ElMessage.error(apiErrorMessage(e)) } finally { loading.value = false }
@@ -72,6 +72,7 @@ async function saveRules() {
try {
const payload = ruleRows.map(r => ({ type: r.type, name: r.name, threshold: r.threshold, isEnabled: r.isEnabled, notifyStudent: r.notifyStudent, notifyCounselor: r.notifyCounselor, description: r.description, autoCheckEnabled: r.autoCheckEnabled, checkDayOfWeek: r.checkDayOfWeek, checkHour: r.checkHour, checkMinute: r.checkMinute }))
await http.put('/warnings/rules', payload, { params: { academicTermId: termId.value } })
await load()
ElMessage.success('预警规则已保存')
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
@@ -102,7 +103,7 @@ onMounted(async () => {
<template>
<div class="page-stack warn-page">
<section class="page-intro">
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isWarningManager ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
<div style="display:flex;gap:8px;align-items:center">
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button>
@@ -110,7 +111,7 @@ onMounted(async () => {
</section>
<!-- Admin: Rule config -->
<section v-if="isSuperAdmin && termId" class="warn-rules">
<section v-if="isWarningManager && termId" class="warn-rules">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0">预警规则配置</h3>
<div>
@@ -160,7 +161,7 @@ onMounted(async () => {
</section>
<!-- Records -->
<section v-if="isSuperAdmin || isCounselor" v-loading="loading" class="warn-records">
<section v-if="isWarningManager || isCounselor" v-loading="loading" class="warn-records">
<div style="display:flex;gap:12px;align-items:center;margin-bottom:12px">
<h3 style="margin:0">预警记录</h3>
<el-select v-model="filterType" clearable placeholder="全部类型" @change="load" style="width:140px"><el-option v-for="(v,k) in typeLabels" :key="k" :label="v" :value="Number(k)" /></el-select>