选课
This commit is contained in:
@@ -10,6 +10,14 @@
|
|||||||
|
|
||||||
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
本地开发固定使用 SQLite。首次启动会自动创建 `src/Jiaowu.Api/data/jiaowu-dev.sqlite` 并写入演示组织数据。
|
||||||
|
|
||||||
|
Development 环境还会按业务编码幂等补齐一套大规模测试数据:16 个常见学院或教学单位、52 个常见本科专业、每个专业 2 个 2026 级行政班、每班 35 名学生、每学院 8 名教师,以及覆盖公共必修、公共选修、专业必修、专业选修和实践教学的 150 余门课程。测试教师同时生成当前学期已审核通过的授课资格;重复启动不会重复累加数据,也不会覆盖已有记录。
|
||||||
|
|
||||||
|
只执行数据库迁移和测试数据补齐、不启动 Web 服务时,可以运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/Jiaowu.Api -- --seed-only
|
||||||
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet run --project src/Jiaowu.Api
|
dotnet run --project src/Jiaowu.Api
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ public sealed class CourseSelectionsController(
|
|||||||
TaskName = x.TeachingTask.Name,
|
TaskName = x.TeachingTask.Name,
|
||||||
CourseCode = x.TeachingTask.Course!.Code,
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course.Name,
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
CourseNature = x.TeachingTask.Course.Nature,
|
||||||
CollegeName = x.TeachingTask.Course.College!.Name,
|
CollegeName = x.TeachingTask.Course.College!.Name,
|
||||||
x.TeachingTask.Course.Credits,
|
x.TeachingTask.Course.Credits,
|
||||||
TeacherNames = x.TeachingTask.Teachers
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
@@ -279,10 +280,14 @@ public sealed class CourseSelectionsController(
|
|||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
TaskName = x.TeachingTask.Name,
|
TaskName = x.TeachingTask.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course!.Name,
|
CourseName = x.TeachingTask.Course!.Name,
|
||||||
|
CourseNature = x.TeachingTask.Course.Nature,
|
||||||
CollegeId = x.TeachingTask.Course.CollegeId,
|
CollegeId = x.TeachingTask.Course.CollegeId,
|
||||||
TeacherUserIds = x.TeachingTask.Teachers
|
TeacherUserIds = x.TeachingTask.Teachers
|
||||||
.Select(item => item.Teacher!.UserId),
|
.Select(item => item.Teacher!.UserId),
|
||||||
|
x.CourseSelectionRoundId,
|
||||||
|
RoundStatus = x.CourseSelectionRound!.Status,
|
||||||
x.Capacity
|
x.Capacity
|
||||||
})
|
})
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
@@ -316,13 +321,281 @@ public sealed class CourseSelectionsController(
|
|||||||
offering.TeachingTaskId,
|
offering.TeachingTaskId,
|
||||||
offering.TaskNumber,
|
offering.TaskNumber,
|
||||||
offering.TaskName,
|
offering.TaskName,
|
||||||
|
offering.CourseCode,
|
||||||
offering.CourseName,
|
offering.CourseName,
|
||||||
|
offering.CourseNature,
|
||||||
offering.Capacity,
|
offering.Capacity,
|
||||||
EnrolledCount = students.Count,
|
EnrolledCount = students.Count,
|
||||||
Students = students
|
Students = students,
|
||||||
|
CanProxyEnroll =
|
||||||
|
CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature) &&
|
||||||
|
offering.RoundStatus != CourseSelectionRoundStatus.Draft &&
|
||||||
|
(currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("offerings/{id:guid}/eligible-students")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> GetEligibleStudents(
|
||||||
|
Guid id,
|
||||||
|
string? keyword = null,
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 20,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
page = Math.Max(1, page);
|
||||||
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
|
var offering = await db.CourseSelectionOfferings.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.IsOpenToAll,
|
||||||
|
CourseNature = x.TeachingTask!.Course!.Nature,
|
||||||
|
RoundStatus = x.CourseSelectionRound!.Status,
|
||||||
|
ClassIds = x.TeachingTask.Classes
|
||||||
|
.Select(item => item.AdministrativeClassId)
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (offering is null) return NotFound();
|
||||||
|
if (!CourseSelectionRules.SupportsProxyEnrollment(offering.CourseNature))
|
||||||
|
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||||
|
if (offering.RoundStatus == CourseSelectionRoundStatus.Draft)
|
||||||
|
return ConflictProblem("选课批次开放后才能办理管理员代选。");
|
||||||
|
|
||||||
|
var source = db.Students.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == StudentStatus.Active &&
|
||||||
|
(offering.IsOpenToAll ||
|
||||||
|
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
|
||||||
|
!db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.CourseSelectionOfferingId == id &&
|
||||||
|
enrollment.StudentId == x.Id &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.StudentNumber.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
x.AdministrativeClass!.Name.Contains(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await source.CountAsync(cancellationToken);
|
||||||
|
var items = await source
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.StudentNumber,
|
||||||
|
x.Name,
|
||||||
|
ClassName = x.AdministrativeClass!.Name,
|
||||||
|
MajorName = x.AdministrativeClass.Major!.Name,
|
||||||
|
CollegeName = x.AdministrativeClass.Major.College!.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return Ok(new { Items = items, Total = total, Page = page, PageSize = pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("offerings/{id:guid}/admin-enrollments")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> AdminEnroll(
|
||||||
|
Guid id,
|
||||||
|
AdminEnrollmentRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var studentIds = request.StudentIds.Distinct().ToArray();
|
||||||
|
if (studentIds.Length == 0)
|
||||||
|
return ValidationProblem("请至少选择一名学生。");
|
||||||
|
if (studentIds.Length > 100)
|
||||||
|
return ValidationProblem("单次最多可为 100 名学生代选。");
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||||
|
IsolationLevel.Serializable,
|
||||||
|
cancellationToken);
|
||||||
|
var offering = await db.CourseSelectionOfferings
|
||||||
|
.Include(x => x.CourseSelectionRound)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Classes)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (offering is null) return NotFound();
|
||||||
|
var round = offering.CourseSelectionRound!;
|
||||||
|
var task = offering.TeachingTask!;
|
||||||
|
if (!CourseSelectionRules.SupportsProxyEnrollment(task.Course!.Nature))
|
||||||
|
return ConflictProblem("管理员代选仅适用于公共必修课。");
|
||||||
|
if (round.Status == CourseSelectionRoundStatus.Draft)
|
||||||
|
return ConflictProblem("选课批次开放后才能办理管理员代选。");
|
||||||
|
if (task.Status != TeachingTaskStatus.Published)
|
||||||
|
return ConflictProblem("该教学班当前不可办理代选。");
|
||||||
|
|
||||||
|
var students = await db.Students
|
||||||
|
.Include(x => x.AdministrativeClass)
|
||||||
|
.Where(x => studentIds.Contains(x.Id))
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (students.Count != studentIds.Length)
|
||||||
|
return ValidationProblem("存在无效的学生档案。");
|
||||||
|
var inactive = students.FirstOrDefault(x => x.Status != StudentStatus.Active);
|
||||||
|
if (inactive is not null)
|
||||||
|
return ConflictProblem($"学生 {inactive.StudentNumber} {inactive.Name} 当前不是在籍状态。");
|
||||||
|
var outOfScope = students.FirstOrDefault(student =>
|
||||||
|
!offering.IsOpenToAll &&
|
||||||
|
!task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == student.AdministrativeClassId));
|
||||||
|
if (outOfScope is not null)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {outOfScope.StudentNumber} {outOfScope.Name} 不属于该教学班的选课对象。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingEnrollments = await db.CourseEnrollments
|
||||||
|
.Where(x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
studentIds.Contains(x.StudentId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled);
|
||||||
|
if (alreadyEnrolled is not null)
|
||||||
|
{
|
||||||
|
var student = students.First(x => x.Id == alreadyEnrolled.StudentId);
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 已在该教学班名单中。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var enrolledCount = await db.CourseEnrollments.CountAsync(
|
||||||
|
x =>
|
||||||
|
x.CourseSelectionOfferingId == id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled,
|
||||||
|
cancellationToken);
|
||||||
|
if (enrolledCount + students.Count > offering.Capacity)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"教学班仅剩 {Math.Max(0, offering.Capacity - enrolledCount)} 个名额,无法完成本次代选。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
studentIds.Contains(x.StudentId) &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||||
|
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||||
|
round.AcademicTermId)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (duplicateStudentIds.Count > 0)
|
||||||
|
{
|
||||||
|
var student = students.First(x => duplicateStudentIds.Contains(x.Id));
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 本学期已选择相同课程。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidateEntries = await PublishedScheduleEntries(
|
||||||
|
round.AcademicTermId,
|
||||||
|
[task.Id],
|
||||||
|
cancellationToken);
|
||||||
|
if (candidateEntries.Count == 0)
|
||||||
|
return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。");
|
||||||
|
|
||||||
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
var selectedCredits = await db.CourseEnrollments
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRoundId == round.Id)
|
||||||
|
.SumAsync(
|
||||||
|
x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits,
|
||||||
|
cancellationToken) ?? 0;
|
||||||
|
if (selectedCredits + task.Course.Credits > round.MaxCredits)
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 代选后将超过本轮 {round.MaxCredits:0.#} 学分上限。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedTaskIds = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId ==
|
||||||
|
round.AcademicTermId)
|
||||||
|
.Select(x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.Distinct()
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
var selectedEntries = await PublishedScheduleEntries(
|
||||||
|
round.AcademicTermId,
|
||||||
|
selectedTaskIds,
|
||||||
|
cancellationToken);
|
||||||
|
if (CourseSelectionRules.HasScheduleConflict(candidateEntries, selectedEntries))
|
||||||
|
{
|
||||||
|
return ConflictProblem(
|
||||||
|
$"学生 {student.StudentNumber} {student.Name} 的已选课程与该教学班时间冲突。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var student in students)
|
||||||
|
{
|
||||||
|
var enrollment = existingEnrollments.FirstOrDefault(x =>
|
||||||
|
x.StudentId == student.Id);
|
||||||
|
if (enrollment is null)
|
||||||
|
{
|
||||||
|
db.CourseEnrollments.Add(new CourseEnrollment
|
||||||
|
{
|
||||||
|
CourseSelectionOfferingId = id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
EnrolledAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
enrollment.Status = CourseEnrollmentStatus.Enrolled;
|
||||||
|
enrollment.EnrolledAt = now;
|
||||||
|
enrollment.WithdrawnAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Ok(new { EnrolledCount = students.Count });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
||||||
|
[Authorize(Roles = RoundManagers)]
|
||||||
|
public async Task<ActionResult> AdminWithdraw(
|
||||||
|
Guid offeringId,
|
||||||
|
Guid enrollmentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var enrollment = await db.CourseEnrollments
|
||||||
|
.Include(x => x.CourseSelectionOffering)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x =>
|
||||||
|
x.Id == enrollmentId &&
|
||||||
|
x.CourseSelectionOfferingId == offeringId,
|
||||||
|
cancellationToken);
|
||||||
|
if (enrollment is null) return NotFound();
|
||||||
|
var offering = enrollment.CourseSelectionOffering!;
|
||||||
|
if (!CourseSelectionRules.SupportsProxyEnrollment(
|
||||||
|
offering.TeachingTask!.Course!.Nature))
|
||||||
|
return ConflictProblem("管理员名单调整仅适用于公共必修课。");
|
||||||
|
if (offering.TeachingTask.Status != TeachingTaskStatus.Published)
|
||||||
|
return ConflictProblem("该教学班当前不可调整名单。");
|
||||||
|
if (enrollment.Status != CourseEnrollmentStatus.Enrolled)
|
||||||
|
return ConflictProblem("该学生已不在教学班名单中。");
|
||||||
|
|
||||||
|
enrollment.Status = CourseEnrollmentStatus.Withdrawn;
|
||||||
|
enrollment.WithdrawnAt = DateTime.UtcNow;
|
||||||
|
return await SaveAsync(enrollmentId, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("student/options")]
|
[HttpGet("student/options")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetStudentOptions(
|
public async Task<ActionResult> GetStudentOptions(
|
||||||
@@ -722,6 +995,9 @@ public sealed record CourseSelectionOfferingRequest(
|
|||||||
|
|
||||||
public sealed record StudentEnrollmentRequest(Guid OfferingId);
|
public sealed record StudentEnrollmentRequest(Guid OfferingId);
|
||||||
|
|
||||||
|
public sealed record AdminEnrollmentRequest(
|
||||||
|
[MinLength(1)] IReadOnlyCollection<Guid> StudentIds);
|
||||||
|
|
||||||
public sealed record StudentOfferingDto(
|
public sealed record StudentOfferingDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid TeachingTaskId,
|
Guid TeachingTaskId,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public static class CourseSelectionRules
|
|||||||
round.Status == CourseSelectionRoundStatus.Open &&
|
round.Status == CourseSelectionRoundStatus.Open &&
|
||||||
nowUtc <= round.WithdrawalEndsAt;
|
nowUtc <= round.WithdrawalEndsAt;
|
||||||
|
|
||||||
|
public static bool SupportsProxyEnrollment(CourseNature nature) =>
|
||||||
|
nature == CourseNature.GeneralRequired;
|
||||||
|
|
||||||
public static bool HasScheduleConflict(
|
public static bool HasScheduleConflict(
|
||||||
IEnumerable<ScheduleEntry> candidateEntries,
|
IEnumerable<ScheduleEntry> candidateEntries,
|
||||||
IEnumerable<ScheduleEntry> selectedEntries) =>
|
IEnumerable<ScheduleEntry> selectedEntries) =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public sealed class DatabaseInitializer(
|
|||||||
RoleManager<ApplicationRole> roleManager,
|
RoleManager<ApplicationRole> roleManager,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
DevelopmentSqliteMigrator sqliteMigrator,
|
DevelopmentSqliteMigrator sqliteMigrator,
|
||||||
|
DevelopmentDemoDataSeeder developmentDemoDataSeeder,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHostEnvironment environment,
|
IHostEnvironment environment,
|
||||||
ILogger<DatabaseInitializer> logger)
|
ILogger<DatabaseInitializer> logger)
|
||||||
@@ -479,6 +480,7 @@ public sealed class DatabaseInitializer(
|
|||||||
await SeedDevelopmentCourseSelectionAsync();
|
await SeedDevelopmentCourseSelectionAsync();
|
||||||
await SeedDevelopmentGradesAsync();
|
await SeedDevelopmentGradesAsync();
|
||||||
await SeedDevelopmentExamsAsync();
|
await SeedDevelopmentExamsAsync();
|
||||||
|
await developmentDemoDataSeeder.SeedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SeedDevelopmentCourseSelectionAsync()
|
private async Task SeedDevelopmentCourseSelectionAsync()
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class DevelopmentDemoDataSeeder(
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<DevelopmentDemoDataSeeder> logger)
|
||||||
|
{
|
||||||
|
private const int Grade = 2026;
|
||||||
|
private const int ClassesPerMajor = 2;
|
||||||
|
private const int StudentsPerClass = 35;
|
||||||
|
private const int TeachersPerCollege = 8;
|
||||||
|
|
||||||
|
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var campus = await db.Campuses
|
||||||
|
.OrderBy(x => x.Code == "MAIN" ? 0 : 1)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (campus is null)
|
||||||
|
{
|
||||||
|
campus = new Campus
|
||||||
|
{
|
||||||
|
Code = "MAIN",
|
||||||
|
Name = "主校区",
|
||||||
|
Address = "大学路 1 号"
|
||||||
|
};
|
||||||
|
db.Campuses.Add(campus);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SeedCollegesAsync(campus.Id, cancellationToken);
|
||||||
|
await SeedMajorsAsync(cancellationToken);
|
||||||
|
await SeedClassesAsync(cancellationToken);
|
||||||
|
await SeedTeachersAsync(cancellationToken);
|
||||||
|
await SeedStudentsAsync(cancellationToken);
|
||||||
|
await SeedCoursesAsync(cancellationToken);
|
||||||
|
await SeedTeacherCourseApplicationsAsync(cancellationToken);
|
||||||
|
await LogSummaryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedCollegesAsync(Guid campusId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existingCodes = (await db.Colleges
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var additions = CollegeDefinitions
|
||||||
|
.Where(x => !existingCodes.Contains(x.Code))
|
||||||
|
.Select((x, index) => new College
|
||||||
|
{
|
||||||
|
Code = x.Code,
|
||||||
|
Name = x.Name,
|
||||||
|
ShortName = x.ShortName,
|
||||||
|
CampusId = campusId,
|
||||||
|
SortOrder = (index + 1) * 10
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.Colleges.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedMajorsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var colleges = await db.Colleges
|
||||||
|
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||||
|
var existingCodes = (await db.Majors
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var additions = new List<Major>();
|
||||||
|
foreach (var collegeDefinition in CollegeDefinitions)
|
||||||
|
{
|
||||||
|
if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue;
|
||||||
|
for (var index = 0; index < collegeDefinition.Majors.Count; index++)
|
||||||
|
{
|
||||||
|
var definition = collegeDefinition.Majors[index];
|
||||||
|
if (existingCodes.Contains(definition.Code)) continue;
|
||||||
|
additions.Add(new Major
|
||||||
|
{
|
||||||
|
Code = definition.Code,
|
||||||
|
Name = definition.Name,
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = definition.DegreeType,
|
||||||
|
SchoolingYears = definition.SchoolingYears,
|
||||||
|
SortOrder = (index + 1) * 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.Majors.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedClassesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var targetMajorCodes = CollegeDefinitions
|
||||||
|
.SelectMany(x => x.Majors)
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var majors = await db.Majors
|
||||||
|
.Where(x => targetMajorCodes.Contains(x.Code))
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingClasses = await db.AdministrativeClasses
|
||||||
|
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingCodes = (await db.AdministrativeClasses
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var additions = new List<AdministrativeClass>();
|
||||||
|
foreach (var major in majors)
|
||||||
|
{
|
||||||
|
var currentCount = existingClasses.Count(x => x.MajorId == major.Id);
|
||||||
|
for (var section = currentCount + 1; section <= ClassesPerMajor; section++)
|
||||||
|
{
|
||||||
|
var code = CreateAvailableCode(
|
||||||
|
$"{major.Code}-{Grade}-{section:D2}",
|
||||||
|
existingCodes);
|
||||||
|
additions.Add(new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = $"{major.Name}{Grade}级{section}班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = Grade,
|
||||||
|
CounselorName = $"{CounselorSurnames[(section + major.Code.Length) % CounselorSurnames.Length]}老师",
|
||||||
|
SortOrder = section * 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.AdministrativeClasses.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedTeachersAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var collegeCodes = CollegeDefinitions
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var colleges = await db.Colleges
|
||||||
|
.Where(x => collegeCodes.Contains(x.Code))
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingTeachers = await db.Teachers
|
||||||
|
.Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingNumbers = (await db.Teachers
|
||||||
|
.Select(x => x.TeacherNumber)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var additions = new List<Teacher>();
|
||||||
|
for (var collegeIndex = 0; collegeIndex < colleges.Count; collegeIndex++)
|
||||||
|
{
|
||||||
|
var college = colleges[collegeIndex];
|
||||||
|
var currentCount = existingTeachers.Count(x => x.CollegeId == college.Id);
|
||||||
|
for (var position = currentCount + 1; position <= TeachersPerCollege; position++)
|
||||||
|
{
|
||||||
|
var teacherNumber = CreateAvailableCode(
|
||||||
|
$"T26{collegeIndex + 1:D2}{position:D3}",
|
||||||
|
existingNumbers);
|
||||||
|
additions.Add(new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = teacherNumber,
|
||||||
|
Name = BuildPersonName(collegeIndex, position),
|
||||||
|
Gender = position % 2 == 0 ? Gender.Female : Gender.Male,
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Title = TeacherTitles[(position - 1) % TeacherTitles.Length],
|
||||||
|
Status = TeacherStatus.Active,
|
||||||
|
HireDate = new DateOnly(2012 + position, 7, 1),
|
||||||
|
Email = $"{teacherNumber.ToLowerInvariant()}@example.edu.cn",
|
||||||
|
Notes = "Development 环境批量测试教师。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.Teachers.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedStudentsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var targetMajorCodes = CollegeDefinitions
|
||||||
|
.SelectMany(x => x.Majors)
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var classes = await db.AdministrativeClasses
|
||||||
|
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var classIds = classes.Select(x => x.Id).ToHashSet();
|
||||||
|
var existingStudents = await db.Students
|
||||||
|
.Where(x => classIds.Contains(x.AdministrativeClassId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var existingNumbers = (await db.Students
|
||||||
|
.Select(x => x.StudentNumber)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var additions = new List<Student>();
|
||||||
|
for (var classIndex = 0; classIndex < classes.Count; classIndex++)
|
||||||
|
{
|
||||||
|
var administrativeClass = classes[classIndex];
|
||||||
|
var existingCount = existingStudents.Count(x =>
|
||||||
|
x.AdministrativeClassId == administrativeClass.Id);
|
||||||
|
var candidate = 1;
|
||||||
|
while (existingCount + additions.Count(x =>
|
||||||
|
x.AdministrativeClassId == administrativeClass.Id) < StudentsPerClass)
|
||||||
|
{
|
||||||
|
var studentNumber = $"{Grade}{classIndex + 1:D3}{candidate:D3}";
|
||||||
|
candidate++;
|
||||||
|
if (!existingNumbers.Add(studentNumber)) continue;
|
||||||
|
additions.Add(new Student
|
||||||
|
{
|
||||||
|
StudentNumber = studentNumber,
|
||||||
|
Name = BuildPersonName(classIndex, candidate + 5),
|
||||||
|
Gender = candidate % 2 == 0 ? Gender.Male : Gender.Female,
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = Grade,
|
||||||
|
EnrollmentDate = new DateOnly(Grade, 9, 7),
|
||||||
|
DateOfBirth = new DateOnly(2007 + candidate % 2, candidate % 12 + 1,
|
||||||
|
candidate % 27 + 1),
|
||||||
|
Status = StudentStatus.Active,
|
||||||
|
Email = $"{studentNumber}@student.example.edu.cn",
|
||||||
|
Notes = "Development 环境批量测试学生。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.Students.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedCoursesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var colleges = await db.Colleges
|
||||||
|
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||||
|
var categories = await db.CourseCategories
|
||||||
|
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||||
|
var existingCodes = (await db.Courses
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var additions = new List<Course>();
|
||||||
|
|
||||||
|
foreach (var definition in PublicCourseDefinitions)
|
||||||
|
{
|
||||||
|
if (existingCodes.Contains(definition.Code) ||
|
||||||
|
!colleges.TryGetValue(definition.CollegeCode, out var college) ||
|
||||||
|
!categories.TryGetValue(definition.CategoryCode, out var category))
|
||||||
|
continue;
|
||||||
|
additions.Add(new Course
|
||||||
|
{
|
||||||
|
Code = definition.Code,
|
||||||
|
Name = definition.Name,
|
||||||
|
CollegeId = college.Id,
|
||||||
|
CourseCategoryId = category.Id,
|
||||||
|
Credits = definition.Credits,
|
||||||
|
TotalHours = definition.TotalHours,
|
||||||
|
LectureHours = definition.LectureHours,
|
||||||
|
PracticeHours = definition.TotalHours - definition.LectureHours,
|
||||||
|
Nature = definition.Nature,
|
||||||
|
AssessmentMethod = definition.AssessmentMethod,
|
||||||
|
Description = "Development 环境公共课程测试数据。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var collegeDefinition in CollegeDefinitions)
|
||||||
|
{
|
||||||
|
if (!colleges.TryGetValue(collegeDefinition.Code, out var college)) continue;
|
||||||
|
for (var index = 0; index < collegeDefinition.ProfessionalCourses.Count; index++)
|
||||||
|
{
|
||||||
|
var code = $"{collegeDefinition.Code}D{index + 1:D2}";
|
||||||
|
if (existingCodes.Contains(code)) continue;
|
||||||
|
var nature = index switch
|
||||||
|
{
|
||||||
|
< 4 => CourseNature.MajorRequired,
|
||||||
|
< 6 => CourseNature.MajorElective,
|
||||||
|
_ => CourseNature.Practice
|
||||||
|
};
|
||||||
|
var categoryCode = nature == CourseNature.Practice ? "PRACTICE" : "MAJOR";
|
||||||
|
additions.Add(new Course
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = collegeDefinition.ProfessionalCourses[index],
|
||||||
|
CollegeId = college.Id,
|
||||||
|
CourseCategoryId = categories[categoryCode].Id,
|
||||||
|
Credits = nature switch
|
||||||
|
{
|
||||||
|
CourseNature.MajorRequired => index % 2 == 0 ? 3m : 3.5m,
|
||||||
|
_ => 2m
|
||||||
|
},
|
||||||
|
TotalHours = nature switch
|
||||||
|
{
|
||||||
|
CourseNature.MajorRequired => index % 2 == 0 ? 48 : 56,
|
||||||
|
CourseNature.MajorElective => 32,
|
||||||
|
_ => 48
|
||||||
|
},
|
||||||
|
LectureHours = nature == CourseNature.Practice ? 8 :
|
||||||
|
nature == CourseNature.MajorElective ? 24 :
|
||||||
|
index % 2 == 0 ? 40 : 48,
|
||||||
|
PracticeHours = nature == CourseNature.Practice ? 40 :
|
||||||
|
nature == CourseNature.MajorElective ? 8 : 8,
|
||||||
|
Nature = nature,
|
||||||
|
AssessmentMethod = nature == CourseNature.Practice
|
||||||
|
? AssessmentMethod.Assessment
|
||||||
|
: AssessmentMethod.Examination,
|
||||||
|
Description = $"由{collegeDefinition.Name}开设的专业课程测试数据。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.Courses.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedTeacherCourseApplicationsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var term = await db.AcademicTerms
|
||||||
|
.OrderByDescending(x => x.IsCurrent)
|
||||||
|
.ThenByDescending(x => x.StartDate)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (term is null) return;
|
||||||
|
|
||||||
|
var collegeCodes = CollegeDefinitions
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var colleges = await db.Colleges
|
||||||
|
.Where(x => collegeCodes.Contains(x.Code))
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
|
||||||
|
var teachers = await db.Teachers
|
||||||
|
.Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active)
|
||||||
|
.OrderBy(x => x.TeacherNumber)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var courses = await db.Courses
|
||||||
|
.Where(x => collegeIds.Contains(x.CollegeId))
|
||||||
|
.OrderBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var publicCourses = courses
|
||||||
|
.Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective)
|
||||||
|
.ToList();
|
||||||
|
if (publicCourses.Count == 0) return;
|
||||||
|
|
||||||
|
var existing = (await db.TeacherCourseApplications
|
||||||
|
.Where(x => x.AcademicTermId == term.Id)
|
||||||
|
.Select(x => new { x.TeacherId, x.CourseId })
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.Select(x => (x.TeacherId, x.CourseId))
|
||||||
|
.ToHashSet();
|
||||||
|
var additions = new List<TeacherCourseApplication>();
|
||||||
|
for (var teacherIndex = 0; teacherIndex < teachers.Count; teacherIndex++)
|
||||||
|
{
|
||||||
|
var teacher = teachers[teacherIndex];
|
||||||
|
var professionalCourses = courses
|
||||||
|
.Where(x => x.CollegeId == teacher.CollegeId &&
|
||||||
|
x.Nature is CourseNature.MajorRequired or
|
||||||
|
CourseNature.MajorElective or CourseNature.Practice)
|
||||||
|
.Take(4);
|
||||||
|
var selectedPublicCourses = Enumerable.Range(0, 2)
|
||||||
|
.Select(offset => publicCourses[(teacherIndex + offset) % publicCourses.Count]);
|
||||||
|
foreach (var course in professionalCourses
|
||||||
|
.Concat(selectedPublicCourses)
|
||||||
|
.DistinctBy(x => x.Id))
|
||||||
|
{
|
||||||
|
if (!existing.Add((teacher.Id, course.Id))) continue;
|
||||||
|
additions.Add(new TeacherCourseApplication
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Status = TeacherCourseApplicationStatus.Approved,
|
||||||
|
Statement = "Development 环境批量生成的授课意向。",
|
||||||
|
ReviewComment = "测试数据自动审核通过。",
|
||||||
|
SubmittedAt = DateTime.UtcNow.AddDays(-7),
|
||||||
|
ReviewedAt = DateTime.UtcNow.AddDays(-6)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (additions.Count == 0) return;
|
||||||
|
db.TeacherCourseApplications.AddRange(additions);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LogSummaryAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var courseCounts = await db.Courses
|
||||||
|
.GroupBy(x => x.Nature)
|
||||||
|
.Select(x => new { Nature = x.Key, Count = x.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Nature, x => x.Count, cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Development 测试数据已就绪:学院/教学单位 {CollegeCount},专业 {MajorCount}," +
|
||||||
|
"行政班 {ClassCount},教师 {TeacherCount},学生 {StudentCount},课程 {CourseCount};" +
|
||||||
|
"公共必修 {GeneralRequiredCount},公共选修 {GeneralElectiveCount}," +
|
||||||
|
"专业必修 {MajorRequiredCount},专业选修 {MajorElectiveCount},实践课程 {PracticeCount};" +
|
||||||
|
"已审核授课资格 {ApplicationCount}。",
|
||||||
|
await db.Colleges.CountAsync(cancellationToken),
|
||||||
|
await db.Majors.CountAsync(cancellationToken),
|
||||||
|
await db.AdministrativeClasses.CountAsync(cancellationToken),
|
||||||
|
await db.Teachers.CountAsync(cancellationToken),
|
||||||
|
await db.Students.CountAsync(cancellationToken),
|
||||||
|
await db.Courses.CountAsync(cancellationToken),
|
||||||
|
courseCounts.GetValueOrDefault(CourseNature.GeneralRequired),
|
||||||
|
courseCounts.GetValueOrDefault(CourseNature.GeneralElective),
|
||||||
|
courseCounts.GetValueOrDefault(CourseNature.MajorRequired),
|
||||||
|
courseCounts.GetValueOrDefault(CourseNature.MajorElective),
|
||||||
|
courseCounts.GetValueOrDefault(CourseNature.Practice),
|
||||||
|
await db.TeacherCourseApplications
|
||||||
|
.CountAsync(x => x.Status == TeacherCourseApplicationStatus.Approved,
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateAvailableCode(string preferredCode, ISet<string> existingCodes)
|
||||||
|
{
|
||||||
|
var candidate = preferredCode;
|
||||||
|
var suffix = 1;
|
||||||
|
while (!existingCodes.Add(candidate))
|
||||||
|
{
|
||||||
|
candidate = $"{preferredCode}-{suffix++}";
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPersonName(int groupIndex, int position)
|
||||||
|
{
|
||||||
|
var surname = Surnames[(groupIndex + position) % Surnames.Length];
|
||||||
|
var givenName = GivenNames[
|
||||||
|
(groupIndex * 7 + position * 3) % GivenNames.Length];
|
||||||
|
return surname + givenName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] Surnames =
|
||||||
|
["王", "李", "张", "刘", "陈", "杨", "黄", "赵", "吴", "周", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"];
|
||||||
|
|
||||||
|
private static readonly string[] GivenNames =
|
||||||
|
["明远", "知夏", "嘉树", "雨桐", "思源", "若溪", "景行", "书雅", "子涵", "浩然", "清越", "语晨", "承宇", "欣怡", "博文", "婉宁", "俊逸", "安然", "泽楷", "可心"];
|
||||||
|
|
||||||
|
private static readonly string[] CounselorSurnames =
|
||||||
|
["陈", "林", "周", "王", "李", "张", "刘", "赵"];
|
||||||
|
|
||||||
|
private static readonly string[] TeacherTitles =
|
||||||
|
["教授", "副教授", "讲师", "讲师", "副教授", "实验师", "讲师", "教授"];
|
||||||
|
|
||||||
|
private static readonly CollegeSeed[] CollegeDefinitions =
|
||||||
|
[
|
||||||
|
new("CS", "计算机学院", "计算机学院",
|
||||||
|
[
|
||||||
|
new("080901", "计算机科学与技术", "工学学士"),
|
||||||
|
new("080902", "软件工程", "工学学士"),
|
||||||
|
new("080903", "网络工程", "工学学士"),
|
||||||
|
new("080910T", "数据科学与大数据技术", "工学学士"),
|
||||||
|
new("080717T", "人工智能", "工学学士")
|
||||||
|
],
|
||||||
|
["程序设计基础", "离散数学", "数据结构", "计算机组成原理", "操作系统", "数据库系统原理", "软件工程课程设计", "人工智能项目实践"]),
|
||||||
|
new("EIA", "电子信息与自动化学院", "电子信息学院",
|
||||||
|
[
|
||||||
|
new("080701", "电子信息工程", "工学学士"),
|
||||||
|
new("080703", "通信工程", "工学学士"),
|
||||||
|
new("080801", "自动化", "工学学士"),
|
||||||
|
new("080803T", "机器人工程", "工学学士")
|
||||||
|
],
|
||||||
|
["电路分析", "模拟电子技术", "数字电子技术", "信号与系统", "通信原理", "嵌入式系统", "电子系统设计", "综合电子实训"]),
|
||||||
|
new("ME", "机械与车辆工程学院", "机械学院",
|
||||||
|
[
|
||||||
|
new("080202", "机械设计制造及其自动化", "工学学士"),
|
||||||
|
new("080207", "车辆工程", "工学学士"),
|
||||||
|
new("080205", "工业设计", "工学学士"),
|
||||||
|
new("080213T", "智能制造工程", "工学学士")
|
||||||
|
],
|
||||||
|
["工程制图", "理论力学", "材料力学", "机械原理", "机械设计", "智能制造技术", "机械创新设计", "工程训练"]),
|
||||||
|
new("EE", "电气工程学院", "电气学院",
|
||||||
|
[
|
||||||
|
new("080601", "电气工程及其自动化", "工学学士"),
|
||||||
|
new("080604T", "电气工程与智能控制", "工学学士"),
|
||||||
|
new("080605T", "电机电器智能化", "工学学士")
|
||||||
|
],
|
||||||
|
["电路原理", "电机学", "电力电子技术", "自动控制原理", "电力系统分析", "继电保护", "电气控制实训", "电力系统综合设计"]),
|
||||||
|
new("CIVIL", "土木建筑工程学院", "土建学院",
|
||||||
|
[
|
||||||
|
new("081001", "土木工程", "工学学士"),
|
||||||
|
new("082801", "建筑学", "建筑学学士", 5),
|
||||||
|
new("120103", "工程管理", "管理学学士"),
|
||||||
|
new("081006T", "道路桥梁与渡河工程", "工学学士")
|
||||||
|
],
|
||||||
|
["工程制图与识图", "工程力学", "结构力学", "混凝土结构", "土力学与地基基础", "工程项目管理", "建筑设计基础", "工程测量实习"]),
|
||||||
|
new("ECON", "经济与管理学院", "经管学院",
|
||||||
|
[
|
||||||
|
new("120201K", "工商管理", "管理学学士"),
|
||||||
|
new("120203K", "会计学", "管理学学士"),
|
||||||
|
new("020301K", "金融学", "经济学学士"),
|
||||||
|
new("020401", "国际经济与贸易", "经济学学士"),
|
||||||
|
new("120202", "市场营销", "管理学学士")
|
||||||
|
],
|
||||||
|
["微观经济学", "宏观经济学", "管理学原理", "会计学原理", "统计学", "财务管理", "企业经营沙盘", "商务数据分析实践"]),
|
||||||
|
new("FOREIGN", "外国语学院", "外国语学院",
|
||||||
|
[
|
||||||
|
new("050201", "英语", "文学学士"),
|
||||||
|
new("050207", "日语", "文学学士"),
|
||||||
|
new("050262", "商务英语", "文学学士")
|
||||||
|
],
|
||||||
|
["综合英语", "英语听力", "英语口语", "英语写作", "翻译理论与实践", "跨文化交际", "商务英语实训", "口译实践"]),
|
||||||
|
new("MATH", "数学与统计学院", "数统学院",
|
||||||
|
[
|
||||||
|
new("070101", "数学与应用数学", "理学学士"),
|
||||||
|
new("071201", "统计学", "理学学士"),
|
||||||
|
new("020102", "经济统计学", "经济学学士")
|
||||||
|
],
|
||||||
|
["数学分析", "高等代数", "解析几何", "常微分方程", "实变函数", "数值分析", "数学建模", "统计软件实践"]),
|
||||||
|
new("PHYSICS", "物理与光电工程学院", "物电学院",
|
||||||
|
[
|
||||||
|
new("070201", "物理学", "理学学士"),
|
||||||
|
new("070202", "应用物理学", "理学学士"),
|
||||||
|
new("080705", "光电信息科学与工程", "工学学士")
|
||||||
|
],
|
||||||
|
["力学", "热学", "电磁学", "光学", "量子力学", "固体物理", "近代物理实验", "光电技术综合实验"]),
|
||||||
|
new("CHEM", "化学与环境工程学院", "化环学院",
|
||||||
|
[
|
||||||
|
new("070301", "化学", "理学学士"),
|
||||||
|
new("070302", "应用化学", "理学学士"),
|
||||||
|
new("081301", "化学工程与工艺", "工学学士"),
|
||||||
|
new("082502", "环境工程", "工学学士")
|
||||||
|
],
|
||||||
|
["无机化学", "有机化学", "分析化学", "物理化学", "化工原理", "仪器分析", "基础化学实验", "化工设计实践"]),
|
||||||
|
new("HUMANITIES", "人文与法学院", "人文法学院",
|
||||||
|
[
|
||||||
|
new("050101", "汉语言文学", "文学学士"),
|
||||||
|
new("030101K", "法学", "法学学士"),
|
||||||
|
new("120402", "行政管理", "管理学学士")
|
||||||
|
],
|
||||||
|
["中国古代文学", "中国现当代文学", "现代汉语", "古代汉语", "文学概论", "行政管理学", "新闻写作实训", "社会调查实践"]),
|
||||||
|
new("EDU", "教育科学学院", "教育学院",
|
||||||
|
[
|
||||||
|
new("040101", "教育学", "教育学学士"),
|
||||||
|
new("040107", "小学教育", "教育学学士"),
|
||||||
|
new("040106", "学前教育", "教育学学士")
|
||||||
|
],
|
||||||
|
["教育学原理", "普通心理学", "教育心理学", "课程与教学论", "教育研究方法", "班级管理", "微格教学", "教育见习"]),
|
||||||
|
new("ART", "艺术设计学院", "艺术学院",
|
||||||
|
[
|
||||||
|
new("130502", "视觉传达设计", "艺术学学士"),
|
||||||
|
new("130503", "环境设计", "艺术学学士"),
|
||||||
|
new("130202", "音乐学", "艺术学学士")
|
||||||
|
],
|
||||||
|
["设计素描", "色彩基础", "构成基础", "艺术概论", "数字媒体设计", "品牌视觉设计", "专业采风", "毕业创作实践"]),
|
||||||
|
new("PE", "体育学院", "体育学院",
|
||||||
|
[
|
||||||
|
new("040201", "体育教育", "教育学学士"),
|
||||||
|
new("040203", "社会体育指导与管理", "教育学学士")
|
||||||
|
],
|
||||||
|
["运动解剖学", "运动生理学", "学校体育学", "体育心理学", "运动训练学", "体育社会学", "田径专项训练", "球类专项训练"]),
|
||||||
|
new("LIFE", "生命科学与食品工程学院", "生食学院",
|
||||||
|
[
|
||||||
|
new("071001", "生物科学", "理学学士"),
|
||||||
|
new("071002", "生物技术", "理学学士"),
|
||||||
|
new("082701", "食品科学与工程", "工学学士")
|
||||||
|
],
|
||||||
|
["普通生物学", "生物化学", "细胞生物学", "遗传学", "微生物学", "食品化学", "分子生物学实验", "生物工程综合实践"]),
|
||||||
|
new("MARXISM", "马克思主义学院", "马克思主义学院", [], [])
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly PublicCourseSeed[] PublicCourseDefinitions =
|
||||||
|
[
|
||||||
|
new("PUB001", "思想道德与法治", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB002", "中国近现代史纲要", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB003", "马克思主义基本原理", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB004", "毛泽东思想和中国特色社会主义理论体系概论", "MARXISM", "MORAL", 5, 80, 64, CourseNature.GeneralRequired),
|
||||||
|
new("PUB005", "习近平新时代中国特色社会主义思想概论", "MARXISM", "MORAL", 3, 48, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB006", "形势与政策", "MARXISM", "MORAL", 2, 32, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB007", "大学英语 I", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB008", "大学英语 II", "FOREIGN", "ENGLISH", 4, 64, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB009", "高等数学 A(上)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired),
|
||||||
|
new("PUB010", "高等数学 A(下)", "MATH", "BASIC", 5, 80, 80, CourseNature.GeneralRequired),
|
||||||
|
new("PUB011", "线性代数", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB012", "概率论与数理统计", "MATH", "BASIC", 3, 48, 48, CourseNature.GeneralRequired),
|
||||||
|
new("PUB013", "大学计算机基础", "CS", "BASIC", 2, 32, 16, CourseNature.GeneralRequired),
|
||||||
|
new("PUB014", "Python 程序设计", "CS", "BASIC", 3, 48, 24, CourseNature.GeneralRequired),
|
||||||
|
new("PUB015", "大学体育 I", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB016", "大学体育 II", "PE", "SPORTS", 1, 32, 4, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB017", "军事理论", "HUMANITIES", "MILITARY", 2, 36, 32, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB018", "大学生心理健康教育", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB019", "大学生职业发展与就业指导", "EDU", "BASIC", 2, 32, 24, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB020", "创新创业基础", "ECON", "INNOVATION", 2, 32, 20, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB021", "劳动教育", "EDU", "LABOR", 1, 32, 8, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB022", "国家安全教育", "HUMANITIES", "MORAL", 1, 16, 16, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB023", "文献检索与学术规范", "HUMANITIES", "BASIC", 1, 16, 12, CourseNature.GeneralRequired, AssessmentMethod.Assessment),
|
||||||
|
new("PUB024", "艺术鉴赏", "ART", "AESTHETIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||||
|
new("PUB025", "中国传统文化", "HUMANITIES", "AESTHETIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||||
|
new("PUB026", "生态文明导论", "LIFE", "BASIC", 2, 32, 24, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||||
|
new("PUB027", "人工智能导论", "CS", "INNOVATION", 2, 32, 20, CourseNature.GeneralElective, AssessmentMethod.Assessment),
|
||||||
|
new("PUB028", "经济学通识", "ECON", "BASIC", 2, 32, 32, CourseNature.GeneralElective, AssessmentMethod.Assessment)
|
||||||
|
];
|
||||||
|
|
||||||
|
private sealed record CollegeSeed(
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string ShortName,
|
||||||
|
IReadOnlyList<MajorSeed> Majors,
|
||||||
|
IReadOnlyList<string> ProfessionalCourses);
|
||||||
|
|
||||||
|
private sealed record MajorSeed(
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string DegreeType,
|
||||||
|
int SchoolingYears = 4);
|
||||||
|
|
||||||
|
private sealed record PublicCourseSeed(
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
string CollegeCode,
|
||||||
|
string CategoryCode,
|
||||||
|
decimal Credits,
|
||||||
|
int TotalHours,
|
||||||
|
int LectureHours,
|
||||||
|
CourseNature Nature,
|
||||||
|
AssessmentMethod AssessmentMethod = AssessmentMethod.Examination);
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
|||||||
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
builder.Services.AddScoped<ICurrentUserDataScope, CurrentUserDataScope>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
builder.Services.AddScoped<DevelopmentSqliteMigrator>();
|
||||||
|
builder.Services.AddScoped<DevelopmentDemoDataSeeder>();
|
||||||
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
builder.Services.AddScoped<AutomaticScheduleGenerator>();
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
@@ -248,6 +249,11 @@ using (var scope = app.Services.CreateScope())
|
|||||||
.InitializeAsync();
|
.InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Contains("--seed-only", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ public sealed class CourseSelectionRulesTests
|
|||||||
[selected]));
|
[selected]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CourseNature.GeneralRequired, true)]
|
||||||
|
[InlineData(CourseNature.GeneralElective, false)]
|
||||||
|
[InlineData(CourseNature.MajorRequired, false)]
|
||||||
|
[InlineData(CourseNature.MajorElective, false)]
|
||||||
|
[InlineData(CourseNature.Practice, false)]
|
||||||
|
public void Proxy_enrollment_is_limited_to_general_required_courses(
|
||||||
|
CourseNature nature,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature));
|
||||||
|
}
|
||||||
|
|
||||||
private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) =>
|
private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -486,11 +486,26 @@ button { cursor: pointer; }
|
|||||||
margin: 0 0 18px; padding: 16px; display: flex; align-items: center; gap: 14px;
|
margin: 0 0 18px; padding: 16px; display: flex; align-items: center; gap: 14px;
|
||||||
color: white; background: linear-gradient(115deg, #1b3067, #294584);
|
color: white; background: linear-gradient(115deg, #1b3067, #294584);
|
||||||
}
|
}
|
||||||
|
.roster-summary > div { min-width: 0; flex: 1; }
|
||||||
|
.roster-summary > .el-button { flex: 0 0 auto; }
|
||||||
.roster-summary > .el-icon { font-size: 28px; color: #5bd4c3; }
|
.roster-summary > .el-icon { font-size: 28px; color: #5bd4c3; }
|
||||||
.roster-summary span, .roster-summary b, .roster-summary small { display: block; }
|
.roster-summary span, .roster-summary b, .roster-summary small { display: block; }
|
||||||
.roster-summary span { color: #59d2c1; font: 700 9px Consolas, monospace; }
|
.roster-summary span { color: #59d2c1; font: 700 9px Consolas, monospace; }
|
||||||
.roster-summary b { margin-top: 5px; font-size: 14px; }
|
.roster-summary b { margin-top: 5px; font-size: 14px; }
|
||||||
.roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; }
|
.roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; }
|
||||||
|
.roster-notice { margin-bottom: 14px; }
|
||||||
|
.proxy-course-note {
|
||||||
|
margin-bottom: 14px; padding: 13px 15px; display: flex; align-items: center;
|
||||||
|
justify-content: space-between; gap: 16px; border-left: 3px solid var(--teal);
|
||||||
|
background: #f5f8fa;
|
||||||
|
}
|
||||||
|
.proxy-course-note b { font-size: 13px; }
|
||||||
|
.proxy-course-note span { color: var(--muted); font-size: 10px; }
|
||||||
|
.proxy-search { margin-bottom: 12px; display: flex; gap: 9px; }
|
||||||
|
.proxy-search .el-input { flex: 1; }
|
||||||
|
.proxy-pagination { margin-top: 14px; justify-content: flex-end; }
|
||||||
|
.proxy-dialog-footer { display: flex; align-items: center; }
|
||||||
|
.proxy-selected-count { margin-right: auto; color: var(--muted); font-size: 11px; }
|
||||||
|
|
||||||
.grade-page { min-width: 0; }
|
.grade-page { min-width: 0; }
|
||||||
.grade-toolbar {
|
.grade-toolbar {
|
||||||
|
|||||||
@@ -31,9 +31,18 @@ const detailLoading = ref(false)
|
|||||||
const roundDialog = ref(false)
|
const roundDialog = ref(false)
|
||||||
const offeringDialog = ref(false)
|
const offeringDialog = ref(false)
|
||||||
const rosterDrawer = ref(false)
|
const rosterDrawer = ref(false)
|
||||||
|
const proxyDialog = ref(false)
|
||||||
const editingRoundId = ref('')
|
const editingRoundId = ref('')
|
||||||
const editingOfferingId = ref('')
|
const editingOfferingId = ref('')
|
||||||
const roster = ref<any | null>(null)
|
const roster = ref<any | null>(null)
|
||||||
|
const rosterLoading = ref(false)
|
||||||
|
const eligibleStudents = ref<any[]>([])
|
||||||
|
const eligibleTotal = ref(0)
|
||||||
|
const eligiblePage = ref(1)
|
||||||
|
const eligibleLoading = ref(false)
|
||||||
|
const proxySubmitting = ref(false)
|
||||||
|
const studentKeyword = ref('')
|
||||||
|
const selectedStudentIds = ref<string[]>([])
|
||||||
const roundForm = reactive<Record<string, any>>({})
|
const roundForm = reactive<Record<string, any>>({})
|
||||||
const offeringForm = reactive<Record<string, any>>({})
|
const offeringForm = reactive<Record<string, any>>({})
|
||||||
|
|
||||||
@@ -286,13 +295,97 @@ async function deleteOffering(offering: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showRoster(offering: any) {
|
async function showRoster(offering: any) {
|
||||||
|
rosterDrawer.value = true
|
||||||
|
await loadRoster(offering.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoster(offeringId: string) {
|
||||||
|
rosterLoading.value = true
|
||||||
try {
|
try {
|
||||||
roster.value = (
|
roster.value = (
|
||||||
await http.get(`/course-selections/offerings/${offering.id}/roster`)
|
await http.get(`/course-selections/offerings/${offeringId}/roster`)
|
||||||
).data
|
).data
|
||||||
rosterDrawer.value = true
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(apiErrorMessage(error))
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
rosterLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProxyEnrollment() {
|
||||||
|
studentKeyword.value = ''
|
||||||
|
selectedStudentIds.value = []
|
||||||
|
eligiblePage.value = 1
|
||||||
|
proxyDialog.value = true
|
||||||
|
await loadEligibleStudents()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEligibleStudents(page = eligiblePage.value) {
|
||||||
|
if (!roster.value) return
|
||||||
|
eligibleLoading.value = true
|
||||||
|
eligiblePage.value = page
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(
|
||||||
|
`/course-selections/offerings/${roster.value.id}/eligible-students`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
keyword: studentKeyword.value.trim() || undefined,
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
eligibleStudents.value = data.items
|
||||||
|
eligibleTotal.value = data.total
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
eligibleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEligibleSelectionChanged(rows: any[]) {
|
||||||
|
selectedStudentIds.value = rows.map((item) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyEnroll() {
|
||||||
|
if (!roster.value || selectedStudentIds.value.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一名学生。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxySubmitting.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.post(
|
||||||
|
`/course-selections/offerings/${roster.value.id}/admin-enrollments`,
|
||||||
|
{ studentIds: selectedStudentIds.value },
|
||||||
|
)
|
||||||
|
ElMessage.success(`已为 ${data.enrolledCount} 名学生完成代选`)
|
||||||
|
proxyDialog.value = false
|
||||||
|
await loadRoster(roster.value.id)
|
||||||
|
if (selectedRound.value) await selectRound(selectedRound.value)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
proxySubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromRoster(student: any) {
|
||||||
|
if (!roster.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认将 ${student.studentNumber} ${student.name} 移出“${roster.value.courseName}”教学班名单?`,
|
||||||
|
'调整教学班名单',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认移出', cancelButtonText: '取消' },
|
||||||
|
)
|
||||||
|
await http.delete(
|
||||||
|
`/course-selections/offerings/${roster.value.id}/admin-enrollments/${student.id}`,
|
||||||
|
)
|
||||||
|
ElMessage.success('已移出教学班名单')
|
||||||
|
await loadRoster(roster.value.id)
|
||||||
|
if (selectedRound.value) await selectRound(selectedRound.value)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +548,7 @@ onMounted(async () => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="190" fixed="right">
|
<el-table-column label="操作" width="190" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="showRoster(row)">查看名单</el-button>
|
<el-button link type="primary" @click="showRoster(row)">名单管理</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="selectedRound.status === 'Draft'"
|
v-if="selectedRound.status === 'Draft'"
|
||||||
link
|
link
|
||||||
@@ -603,26 +696,98 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-drawer v-model="rosterDrawer" title="教学班名单" size="620px">
|
<el-drawer v-model="rosterDrawer" title="教学班名单管理" size="720px">
|
||||||
<template v-if="roster">
|
<template v-if="roster">
|
||||||
<div class="roster-summary">
|
<div class="roster-summary">
|
||||||
<el-icon><UserFilled /></el-icon>
|
<el-icon><UserFilled /></el-icon>
|
||||||
<div>
|
<div>
|
||||||
<span>{{ roster.taskNumber }}</span>
|
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
|
||||||
<b>{{ roster.taskName }}</b>
|
<b>{{ roster.taskName }}</b>
|
||||||
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} 人</small>
|
<small>{{ roster.enrolledCount }} / {{ roster.capacity }} 人</small>
|
||||||
</div>
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="roster.canProxyEnroll"
|
||||||
|
type="primary"
|
||||||
|
:icon="Plus"
|
||||||
|
@click="openProxyEnrollment"
|
||||||
|
>代选学生</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="roster.students">
|
<el-alert
|
||||||
|
v-if="roster.canProxyEnroll"
|
||||||
|
class="roster-notice"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
title="公共必修课支持校级教务代选;系统仍会校验教学班容量、学分上限、重复课程和课表冲突。"
|
||||||
|
/>
|
||||||
|
<el-table v-loading="rosterLoading" :data="roster.students">
|
||||||
<el-table-column prop="studentNumber" label="学号" width="130" />
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="90" />
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
<el-table-column prop="className" label="行政班" min-width="150" />
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
<el-table-column label="选课时间" min-width="130">
|
<el-table-column label="选课时间" min-width="130">
|
||||||
<template #default="{ row }">{{ formatDateTime(row.enrolledAt) }}</template>
|
<template #default="{ row }">{{ formatDateTime(row.enrolledAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column v-if="roster.canProxyEnroll" label="操作" width="80" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="danger" @click="removeFromRoster(row)">移出</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无学生选课" /></template>
|
<template #empty><el-empty description="暂无学生选课" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</template>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="proxyDialog" title="公共必修课代选" width="760px">
|
||||||
|
<template v-if="roster">
|
||||||
|
<div class="proxy-course-note">
|
||||||
|
<b>{{ roster.courseName }}</b>
|
||||||
|
<span>{{ roster.taskNumber }} · 当前 {{ roster.enrolledCount }} / {{ roster.capacity }} 人</span>
|
||||||
|
</div>
|
||||||
|
<div class="proxy-search">
|
||||||
|
<el-input
|
||||||
|
v-model="studentKeyword"
|
||||||
|
clearable
|
||||||
|
placeholder="输入学号、姓名或行政班"
|
||||||
|
@keyup.enter="loadEligibleStudents(1)"
|
||||||
|
@clear="loadEligibleStudents(1)"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="loadEligibleStudents(1)">查询学生</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="eligibleLoading"
|
||||||
|
:data="eligibleStudents"
|
||||||
|
row-key="id"
|
||||||
|
height="360"
|
||||||
|
@selection-change="onEligibleSelectionChanged"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column prop="studentNumber" label="学号" width="130" />
|
||||||
|
<el-table-column prop="name" label="姓名" width="90" />
|
||||||
|
<el-table-column prop="className" label="行政班" min-width="150" />
|
||||||
|
<el-table-column prop="majorName" label="专业" min-width="150" />
|
||||||
|
<template #empty><el-empty description="没有可代选的在籍学生" /></template>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="eligibleTotal > 20"
|
||||||
|
class="proxy-pagination"
|
||||||
|
layout="prev, pager, next, total"
|
||||||
|
:current-page="eligiblePage"
|
||||||
|
:page-size="20"
|
||||||
|
:total="eligibleTotal"
|
||||||
|
@current-change="loadEligibleStudents"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="proxy-dialog-footer">
|
||||||
|
<span class="proxy-selected-count">已选择 {{ selectedStudentIds.length }} 人</span>
|
||||||
|
<el-button @click="proxyDialog = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="proxySubmitting"
|
||||||
|
:disabled="selectedStudentIds.length === 0"
|
||||||
|
@click="proxyEnroll"
|
||||||
|
>确认代选</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user