From 0b463fa4f228afb18e4196a559f58dfbd4ada91f Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 24 Jul 2026 14:27:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=92=E8=AF=BE=E7=89=88=E6=9C=AC=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E3=80=81=E7=BC=96=E8=BE=91=E3=80=81=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E3=80=81=E5=8F=91=E5=B8=83=E3=80=81=E5=BD=92=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E3=80=82=20=E5=91=A8=E8=AF=BE=E8=A1=A8?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=EF=BC=8C=E6=94=AF=E6=8C=8112=E8=8A=82?= =?UTF-8?q?=E8=AF=BE=E3=80=81=E5=8D=95=E5=8F=8C=E5=91=A8=E5=92=8C=E8=B5=B7?= =?UTF-8?q?=E6=AD=A2=E5=91=A8=E3=80=82=20=E8=87=AA=E5=8A=A8=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=95=99=E5=B8=88=E3=80=81=E8=A1=8C=E6=94=BF=E7=8F=AD?= =?UTF-8?q?=E3=80=81=E6=95=99=E5=AE=A4=E3=80=81=E6=97=B6=E9=97=B4=E6=AE=B5?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E3=80=82=20=E6=A0=A1=E9=AA=8C=E6=95=99?= =?UTF-8?q?=E5=AE=A4=E5=AE=B9=E9=87=8F=E3=80=81=E6=95=99=E5=AD=A6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=8A=B6=E6=80=81=E5=92=8C=E5=AD=A6=E6=9C=9F=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=82=20SQLite=20=E5=BC=80=E5=8F=91=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E8=87=AA=E5=8A=A8=E5=8D=87=E7=BA=A7=EF=BC=8CMySQL=20?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=AD=A3=E5=BC=8F=20EF=20Core=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E3=80=82=20Vue=20=E5=B7=B2=E7=BC=96=E8=AF=91=E4=B8=BA?= =?UTF-8?q?=E9=9D=99=E6=80=81=E6=96=87=E4=BB=B6=E5=B9=B6=E9=9A=8F=20ASP.NE?= =?UTF-8?q?T=20Core=20=E5=8F=91=E5=B8=83=EF=BC=8C=E6=97=A0=E9=9C=80=20npm?= =?UTF-8?q?=20run=20dev=E3=80=82=20=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=92=8C390?= =?UTF-8?q?px=E7=A7=BB=E5=8A=A8=E7=AB=AF=E9=A1=B5=E9=9D=A2=E5=9D=87?= =?UTF-8?q?=E5=B7=B2=E9=AA=8C=E6=94=B6=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- scripts/smoke-test.ps1 | 8 + .../Controllers/DashboardController.cs | 1 + .../Controllers/SchedulesController.cs | 406 +++++ .../Domain/Academic/ScheduleEntities.cs | 46 + .../Persistence/AppDbContext.cs | 38 + .../Persistence/DatabaseInitializer.cs | 30 + .../Persistence/DevelopmentSqliteMigrator.cs | 67 + .../20260724061413_Schedules.Designer.cs | 1471 +++++++++++++++++ .../MySql/20260724061413_Schedules.cs | 119 ++ .../MySql/AppDbContextModelSnapshot.cs | 142 ++ .../Scheduling/ScheduleConflictDetector.cs | 62 + tests/Jiaowu.Api.Tests/PersistenceTests.cs | 51 +- .../Jiaowu.Api.Tests/ScheduleConflictTests.cs | 81 + web/src/layouts/AdminLayout.vue | 11 +- web/src/router/index.ts | 6 + web/src/style.css | 44 + web/src/views/DashboardView.vue | 9 +- web/src/views/SchedulesView.vue | 365 ++++ 19 files changed, 2950 insertions(+), 9 deletions(-) create mode 100644 src/Jiaowu.Api/Controllers/SchedulesController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs create mode 100644 tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs create mode 100644 web/src/views/SchedulesView.vue diff --git a/README.md b/README.md index 114a8dd..9033aca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 -当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课。 +当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。 ## 本地开发:热更新模式 diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 index 015268b..376ae05 100644 --- a/scripts/smoke-test.ps1 +++ b/scripts/smoke-test.ps1 @@ -76,6 +76,12 @@ try { -Headers $headers } $teachingTasks = Invoke-RestMethod -Uri 'http://localhost:5255/api/teaching-tasks?page=1&pageSize=10' -Headers $headers + $schedulePlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/schedules/plans' -Headers $headers + if (@($schedulePlans).Count -gt 0) { + $scheduleDetail = Invoke-RestMethod ` + -Uri "http://localhost:5255/api/schedules/plans/$($schedulePlans[0].id)" ` + -Headers $headers + } $frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5 $spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5 $unknownApiParameters = @{ @@ -97,6 +103,8 @@ try { Plans = $curriculumPlans.total PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 } TeachingTasks = $teachingTasks.total + Schedules = @($schedulePlans).Count + ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 } StaticIndex = $frontend.Content.Contains('明序教务管理系统') SpaFallback = $spaFallback.StatusCode ApiNotFound = $unknownApi.StatusCode diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index 57fc261..2a7616d 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -34,6 +34,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase Courses = await db.Courses.CountAsync(cancellationToken), CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), + SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), Users = await db.Users.CountAsync(cancellationToken) } }; diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs new file mode 100644 index 0000000..071c160 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -0,0 +1,406 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Scheduling; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize(Roles = ManagementRoles)] +[Route("api/schedules")] +public sealed class SchedulesController(AppDbContext db) : ControllerBase +{ + private const string ManagementRoles = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin; + + [HttpGet("plans")] + public async Task GetPlans( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var source = db.SchedulePlans.AsNoTracking().AsQueryable(); + if (academicTermId.HasValue) + source = source.Where(x => x.AcademicTermId == academicTermId); + return Ok(await source + .OrderByDescending(x => x.AcademicTerm!.StartDate) + .ThenByDescending(x => x.CreatedAt) + .Select(x => new + { + x.Id, + x.Name, + x.Version, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.Status, + x.PublishedAt, + EntryCount = x.Entries.Count, + x.UpdatedAt + }) + .ToListAsync(cancellationToken)); + } + + [HttpGet("plans/{id:guid}")] + public async Task GetPlan(Guid id, CancellationToken cancellationToken) + { + var plan = await db.SchedulePlans.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new + { + x.Id, + x.Name, + x.Version, + x.AcademicTermId, + TermName = x.AcademicTerm!.Name, + x.Status, + x.Notes, + x.PublishedAt, + Entries = x.Entries + .OrderBy(entry => entry.DayOfWeek) + .ThenBy(entry => entry.StartPeriod) + .Select(entry => new + { + entry.Id, + entry.TeachingTaskId, + TaskNumber = entry.TeachingTask!.TaskNumber, + TaskName = entry.TeachingTask.Name, + CourseCode = entry.TeachingTask.Course!.Code, + CourseName = entry.TeachingTask.Course.Name, + TeacherNames = entry.TeachingTask.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + ClassNames = entry.TeachingTask.Classes + .Select(item => item.AdministrativeClass!.Name), + entry.ClassroomId, + ClassroomName = entry.Classroom!.Name, + BuildingName = entry.Classroom.Building!.Name, + CampusName = entry.Classroom.Building.Campus!.Name, + entry.DayOfWeek, + entry.StartPeriod, + entry.PeriodCount, + entry.StartWeek, + entry.EndWeek, + entry.WeekPattern, + entry.Notes + }) + }) + .FirstOrDefaultAsync(cancellationToken); + return plan is null ? NotFound() : Ok(plan); + } + + [HttpPost("plans")] + public async Task CreatePlan( + SchedulePlanRequest request, + CancellationToken cancellationToken) + { + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选学期不存在或已停用。"); + var plan = new SchedulePlan + { + AcademicTermId = request.AcademicTermId, + Name = request.Name.Trim(), + Version = request.Version.Trim(), + Notes = Normalize(request.Notes) + }; + db.SchedulePlans.Add(plan); + return await SaveAsync(plan.Id, true, cancellationToken); + } + + [HttpPut("plans/{id:guid}")] + public async Task UpdatePlan( + Guid id, + SchedulePlanRequest request, + CancellationToken cancellationToken) + { + var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != SchedulePlanStatus.Draft) + return ConflictProblem("已发布或已归档的排课版本不可直接修改。"); + if (!await db.AcademicTerms.AnyAsync( + x => x.Id == request.AcademicTermId && x.IsEnabled, + cancellationToken)) + return ValidationProblem("所选学期不存在或已停用。"); + plan.AcademicTermId = request.AcademicTermId; + plan.Name = request.Name.Trim(); + plan.Version = request.Version.Trim(); + plan.Notes = Normalize(request.Notes); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("plans/{id:guid}/clone")] + public async Task ClonePlan( + Guid id, + CloneSchedulePlanRequest request, + CancellationToken cancellationToken) + { + var source = await db.SchedulePlans.AsNoTracking() + .Include(x => x.Entries) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (source is null) return NotFound(); + var copy = new SchedulePlan + { + AcademicTermId = source.AcademicTermId, + Name = request.Name.Trim(), + Version = request.Version.Trim(), + Notes = source.Notes, + Entries = source.Entries.Select(entry => new ScheduleEntry + { + TeachingTaskId = entry.TeachingTaskId, + ClassroomId = entry.ClassroomId, + DayOfWeek = entry.DayOfWeek, + StartPeriod = entry.StartPeriod, + PeriodCount = entry.PeriodCount, + StartWeek = entry.StartWeek, + EndWeek = entry.EndWeek, + WeekPattern = entry.WeekPattern, + Notes = entry.Notes + }).ToList() + }; + db.SchedulePlans.Add(copy); + return await SaveAsync(copy.Id, true, cancellationToken); + } + + [HttpDelete("plans/{id:guid}")] + public async Task DeletePlan(Guid id, CancellationToken cancellationToken) + { + var plan = await db.SchedulePlans.FindAsync([id], cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != SchedulePlanStatus.Draft) + return ConflictProblem("仅草稿排课版本可以删除。"); + db.SchedulePlans.Remove(plan); + return await SaveAsync(id, false, cancellationToken); + } + + [HttpPost("plans/{id:guid}/publish")] + public async Task PublishPlan(Guid id, CancellationToken cancellationToken) + { + var plan = await db.SchedulePlans + .Include(x => x.Entries) + .ThenInclude(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .Include(x => x.Entries) + .ThenInclude(x => x.TeachingTask) + .ThenInclude(x => x!.Classes) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (plan is null) return NotFound(); + if (plan.Status != SchedulePlanStatus.Draft) + return ConflictProblem("只有草稿排课版本可以发布。"); + if (plan.Entries.Count == 0) + return ConflictProblem("排课版本中至少需要一条课表安排。"); + + var conflict = ScheduleConflictDetector.FindConflict(plan.Entries.ToList()); + if (conflict is not null) return ConflictProblem(conflict); + + await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); + var previous = await db.SchedulePlans + .Where(x => + x.Id != plan.Id && + x.AcademicTermId == plan.AcademicTermId && + x.Status == SchedulePlanStatus.Published) + .ToListAsync(cancellationToken); + foreach (var oldPlan in previous) oldPlan.Status = SchedulePlanStatus.Archived; + plan.Status = SchedulePlanStatus.Published; + plan.PublishedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return NoContent(); + } + + [HttpPost("plans/{planId:guid}/entries")] + public async Task CreateEntry( + Guid planId, + ScheduleEntryRequest request, + CancellationToken cancellationToken) + { + var plan = await DraftPlanAsync(planId, cancellationToken); + if (plan is null) return NotFound(); + var validation = await ValidateEntryAsync(plan, null, request, cancellationToken); + if (validation is not null) return validation; + var entry = CreateEntryEntity(planId, request); + db.ScheduleEntries.Add(entry); + return await SaveAsync(entry.Id, true, cancellationToken); + } + + [HttpPut("plans/{planId:guid}/entries/{entryId:guid}")] + public async Task UpdateEntry( + Guid planId, + Guid entryId, + ScheduleEntryRequest request, + CancellationToken cancellationToken) + { + var plan = await DraftPlanAsync(planId, cancellationToken); + if (plan is null) return NotFound(); + var entry = await db.ScheduleEntries + .FirstOrDefaultAsync( + x => x.Id == entryId && x.SchedulePlanId == planId, + cancellationToken); + if (entry is null) return NotFound(); + var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken); + if (validation is not null) return validation; + entry.TeachingTaskId = request.TeachingTaskId; + entry.ClassroomId = request.ClassroomId; + entry.DayOfWeek = request.DayOfWeek; + entry.StartPeriod = request.StartPeriod; + entry.PeriodCount = request.PeriodCount; + entry.StartWeek = request.StartWeek; + entry.EndWeek = request.EndWeek; + entry.WeekPattern = request.WeekPattern; + entry.Notes = Normalize(request.Notes); + return await SaveAsync(entryId, false, cancellationToken); + } + + [HttpDelete("plans/{planId:guid}/entries/{entryId:guid}")] + public async Task DeleteEntry( + Guid planId, + Guid entryId, + CancellationToken cancellationToken) + { + if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound(); + var entry = await db.ScheduleEntries + .FirstOrDefaultAsync( + x => x.Id == entryId && x.SchedulePlanId == planId, + cancellationToken); + if (entry is null) return NotFound(); + db.ScheduleEntries.Remove(entry); + return await SaveAsync(entryId, false, cancellationToken); + } + + private async Task DraftPlanAsync( + Guid id, + CancellationToken cancellationToken) => + await db.SchedulePlans.FirstOrDefaultAsync( + x => x.Id == id && x.Status == SchedulePlanStatus.Draft, + cancellationToken); + + private async Task ValidateEntryAsync( + SchedulePlan plan, + Guid? entryId, + ScheduleEntryRequest request, + CancellationToken cancellationToken) + { + if (request.StartWeek > request.EndWeek) + return ValidationProblem("开始周不能晚于结束周。"); + if (request.StartPeriod + request.PeriodCount - 1 > 12) + return ValidationProblem("结束节次不能超过第 12 节。"); + + var task = await db.TeachingTasks.AsNoTracking() + .Include(x => x.Teachers) + .Include(x => x.Classes) + .ThenInclude(x => x.AdministrativeClass) + .ThenInclude(x => x!.Students) + .FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken); + if (task is null || + task.Status != TeachingTaskStatus.Published || + task.AcademicTermId != plan.AcademicTermId) + return ValidationProblem("只能安排同一学期内已发布的教学任务。"); + if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek) + return ValidationProblem("排课周次必须位于教学任务的授课周次内。"); + + var classroom = await db.Classrooms.AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.ClassroomId && x.IsEnabled, + cancellationToken); + if (classroom is null) return ValidationProblem("所选教室不存在或已停用。"); + var studentCount = task.Classes.Sum(x => + x.AdministrativeClass!.Students.Count(student => + student.Status == StudentStatus.Active)); + if (studentCount > classroom.Capacity) + return ConflictProblem( + $"教室容量不足:教学班有 {studentCount} 名学生,教室仅容纳 {classroom.Capacity} 人。"); + + var candidates = await db.ScheduleEntries.AsNoTracking() + .Where(x => + x.SchedulePlanId == plan.Id && + x.Id != entryId && + x.DayOfWeek == request.DayOfWeek && + x.StartWeek <= request.EndWeek && + x.EndWeek >= request.StartWeek && + x.StartPeriod < request.StartPeriod + request.PeriodCount && + request.StartPeriod < x.StartPeriod + x.PeriodCount) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Teachers) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Classes) + .ToListAsync(cancellationToken); + var proposed = CreateEntryEntity(plan.Id, request); + proposed.TeachingTask = task; + foreach (var candidate in candidates) + { + if (!ScheduleConflictDetector.TimeOverlaps(candidate, proposed)) continue; + var reason = ScheduleConflictDetector.ConflictReason(candidate, proposed); + if (reason is not null) + return ConflictProblem( + $"与“{candidate.TeachingTask!.Name}”发生{reason}冲突。"); + } + return null; + } + + private static ScheduleEntry CreateEntryEntity(Guid planId, ScheduleEntryRequest request) => + new() + { + SchedulePlanId = planId, + TeachingTaskId = request.TeachingTaskId, + ClassroomId = request.ClassroomId, + DayOfWeek = request.DayOfWeek, + StartPeriod = request.StartPeriod, + PeriodCount = request.PeriodCount, + StartWeek = request.StartWeek, + EndWeek = request.EndWeek, + WeekPattern = request.WeekPattern, + Notes = Normalize(request.Notes) + }; + + private async Task SaveAsync( + Guid id, + bool created, + CancellationToken cancellationToken) + { + try + { + await db.SaveChangesAsync(cancellationToken); + return created ? Created(string.Empty, new { id }) : 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 SchedulePlanRequest( + Guid AcademicTermId, + [Required, MaxLength(120)] string Name, + [Required, MaxLength(30)] string Version, + [MaxLength(500)] string? Notes); + +public sealed record CloneSchedulePlanRequest( + [Required, MaxLength(120)] string Name, + [Required, MaxLength(30)] string Version); + +public sealed record ScheduleEntryRequest( + Guid TeachingTaskId, + Guid ClassroomId, + [Range(1, 7)] int DayOfWeek, + [Range(1, 12)] int StartPeriod, + [Range(1, 6)] int PeriodCount, + [Range(1, 30)] int StartWeek, + [Range(1, 30)] int EndWeek, + WeekPattern WeekPattern, + [MaxLength(500)] string? Notes); diff --git a/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs new file mode 100644 index 0000000..9cc5e52 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/ScheduleEntities.cs @@ -0,0 +1,46 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class SchedulePlan : EntityBase +{ + public Guid AcademicTermId { get; set; } + public AcademicTerm? AcademicTerm { get; set; } + public required string Name { get; set; } + public required string Version { get; set; } + public SchedulePlanStatus Status { get; set; } = SchedulePlanStatus.Draft; + public string? Notes { get; set; } + public DateTime? PublishedAt { get; set; } + public ICollection Entries { get; set; } = []; +} + +public sealed class ScheduleEntry : EntityBase +{ + public Guid SchedulePlanId { get; set; } + public SchedulePlan? SchedulePlan { get; set; } + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public Guid ClassroomId { get; set; } + public Classroom? Classroom { get; set; } + public int DayOfWeek { get; set; } + public int StartPeriod { get; set; } + public int PeriodCount { get; set; } + public int StartWeek { get; set; } + public int EndWeek { get; set; } + public WeekPattern WeekPattern { get; set; } + public string? Notes { get; set; } +} + +public enum SchedulePlanStatus +{ + Draft = 1, + Published = 2, + Archived = 3 +} + +public enum WeekPattern +{ + All = 1, + Odd = 2, + Even = 3 +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 5566621..c51504c 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet TeachingTasks => Set(); public DbSet TeachingTaskTeachers => Set(); public DbSet TeachingTaskClasses => Set(); + public DbSet SchedulePlans => Set(); + public DbSet ScheduleEntries => Set(); public DbSet AuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -221,6 +223,42 @@ public sealed class AppDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Name).HasMaxLength(120); + entity.Property(x => x.Version).HasMaxLength(30); + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new { x.AcademicTermId, x.Version }).IsUnique(); + entity.HasIndex(x => new { x.AcademicTermId, x.Status }); + entity.HasOne(x => x.AcademicTerm) + .WithMany() + .HasForeignKey(x => x.AcademicTermId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Notes).HasMaxLength(500); + entity.HasIndex(x => new + { + x.SchedulePlanId, + x.DayOfWeek, + x.StartPeriod + }); + entity.HasOne(x => x.SchedulePlan) + .WithMany(x => x.Entries) + .HasForeignKey(x => x.SchedulePlanId) + .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.Restrict); + }); + builder.Entity(entity => { entity.Property(x => x.Method).HasMaxLength(10); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs index 2ee99b6..6978d5c 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs @@ -389,6 +389,36 @@ public sealed class DatabaseInitializer( }); await db.SaveChangesAsync(); } + + if (!await db.SchedulePlans.AnyAsync()) + { + var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent); + var task = await db.TeachingTasks.SingleAsync(x => x.TaskNumber == "2026-1-CS101-01"); + var classroom = await db.Classrooms.SingleAsync(x => x.Code == "J1-201"); + db.SchedulePlans.Add(new SchedulePlan + { + AcademicTermId = term.Id, + Name = "2026—2027 学年第一学期正式课表", + Version = "V1", + Status = SchedulePlanStatus.Published, + PublishedAt = DateTime.UtcNow, + Entries = + [ + new ScheduleEntry + { + TeachingTaskId = task.Id, + ClassroomId = classroom.Id, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 16, + WeekPattern = WeekPattern.All + } + ] + }); + await db.SaveChangesAsync(); + } } private static void EnsureSucceeded(IdentityResult result, string action) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index 740f2dd..84dfe09 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -9,6 +9,7 @@ public sealed class DevelopmentSqliteMigrator( private const string PeopleAndCoursesMigration = "20260724_01_people_courses"; private const string CurriculumPlansMigration = "20260724_02_curriculum_plans"; private const string TeachingTasksMigration = "20260724_03_teaching_tasks"; + private const string SchedulesMigration = "20260724_04_schedules"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -38,6 +39,10 @@ public sealed class DevelopmentSqliteMigrator( TeachingTasksMigration, TeachingTasksStatements, cancellationToken); + await ApplyMigrationAsync( + SchedulesMigration, + SchedulesStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -332,4 +337,66 @@ public sealed class DevelopmentSqliteMigrator( ON "TeachingTaskClasses" ("AdministrativeClassId"); """ ]; + + private static readonly string[] SchedulesStatements = + [ + """ + CREATE TABLE IF NOT EXISTS "SchedulePlans" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_SchedulePlans" PRIMARY KEY, + "AcademicTermId" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Version" TEXT NOT NULL, + "Status" INTEGER NOT NULL, + "Notes" TEXT NULL, + "PublishedAt" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_SchedulePlans_AcademicTerms_AcademicTermId" + FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_SchedulePlans_AcademicTermId_Version" + ON "SchedulePlans" ("AcademicTermId", "Version"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_SchedulePlans_AcademicTermId_Status" + ON "SchedulePlans" ("AcademicTermId", "Status"); + """, + """ + CREATE TABLE IF NOT EXISTS "ScheduleEntries" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_ScheduleEntries" PRIMARY KEY, + "SchedulePlanId" TEXT NOT NULL, + "TeachingTaskId" TEXT NOT NULL, + "ClassroomId" TEXT NOT NULL, + "DayOfWeek" INTEGER NOT NULL, + "StartPeriod" INTEGER NOT NULL, + "PeriodCount" INTEGER NOT NULL, + "StartWeek" INTEGER NOT NULL, + "EndWeek" INTEGER NOT NULL, + "WeekPattern" INTEGER NOT NULL, + "Notes" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_ScheduleEntries_SchedulePlans_SchedulePlanId" + FOREIGN KEY ("SchedulePlanId") REFERENCES "SchedulePlans" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_ScheduleEntries_TeachingTasks_TeachingTaskId" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_ScheduleEntries_Classrooms_ClassroomId" + FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE RESTRICT + ); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod" + ON "ScheduleEntries" ("SchedulePlanId", "DayOfWeek", "StartPeriod"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_TeachingTaskId" + ON "ScheduleEntries" ("TeachingTaskId"); + """, + """ + CREATE INDEX IF NOT EXISTS "IX_ScheduleEntries_ClassroomId" + ON "ScheduleEntries" ("ClassroomId"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.Designer.cs new file mode 100644 index 0000000..f8760d1 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.Designer.cs @@ -0,0 +1,1471 @@ +// +using System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260724061413_Schedules")] + partial class Schedules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.cs new file mode 100644 index 0000000..a7b6a53 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260724061413_Schedules.cs @@ -0,0 +1,119 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class Schedules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SchedulePlans", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + AcademicTermId = table.Column(type: "char(36)", nullable: false), + Name = table.Column(type: "varchar(120)", maxLength: 120, nullable: false), + Version = table.Column(type: "varchar(30)", maxLength: 30, nullable: false), + Status = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + PublishedAt = table.Column(type: "datetime(6)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SchedulePlans", x => x.Id); + table.ForeignKey( + name: "FK_SchedulePlans_AcademicTerms_AcademicTermId", + column: x => x.AcademicTermId, + principalTable: "AcademicTerms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "ScheduleEntries", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + SchedulePlanId = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + ClassroomId = table.Column(type: "char(36)", nullable: false), + DayOfWeek = table.Column(type: "int", nullable: false), + StartPeriod = table.Column(type: "int", nullable: false), + PeriodCount = table.Column(type: "int", nullable: false), + StartWeek = table.Column(type: "int", nullable: false), + EndWeek = table.Column(type: "int", nullable: false), + WeekPattern = table.Column(type: "int", nullable: false), + Notes = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ScheduleEntries", x => x.Id); + table.ForeignKey( + name: "FK_ScheduleEntries_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ScheduleEntries_SchedulePlans_SchedulePlanId", + column: x => x.SchedulePlanId, + principalTable: "SchedulePlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ScheduleEntries_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleEntries_ClassroomId", + table: "ScheduleEntries", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleEntries_SchedulePlanId_DayOfWeek_StartPeriod", + table: "ScheduleEntries", + columns: new[] { "SchedulePlanId", "DayOfWeek", "StartPeriod" }); + + migrationBuilder.CreateIndex( + name: "IX_ScheduleEntries_TeachingTaskId", + table: "ScheduleEntries", + column: "TeachingTaskId"); + + migrationBuilder.CreateIndex( + name: "IX_SchedulePlans_AcademicTermId_Status", + table: "SchedulePlans", + columns: new[] { "AcademicTermId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_SchedulePlans_AcademicTermId_Version", + table: "SchedulePlans", + columns: new[] { "AcademicTermId", "Version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ScheduleEntries"); + + migrationBuilder.DropTable( + name: "SchedulePlans"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 5f55589..440075e 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -557,6 +557,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("Majors"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => { b.Property("Id") @@ -1159,6 +1258,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("College"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => { b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") @@ -1314,6 +1451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Modules"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => { b.Navigation("Classes"); diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs new file mode 100644 index 0000000..69ae9ba --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/ScheduleConflictDetector.cs @@ -0,0 +1,62 @@ +using Jiaowu.Api.Domain.Academic; + +namespace Jiaowu.Api.Infrastructure.Scheduling; + +public static class ScheduleConflictDetector +{ + public static string? FindConflict(IReadOnlyList entries) + { + for (var i = 0; i < entries.Count; i++) + { + for (var j = i + 1; j < entries.Count; j++) + { + var first = entries[i]; + var second = entries[j]; + if (!TimeOverlaps(first, second)) continue; + var reason = ConflictReason(first, second); + if (reason is not null) + return $"“{first.TeachingTask!.Name}”与“{second.TeachingTask!.Name}”存在{reason}冲突。"; + } + } + return null; + } + + public static bool TimeOverlaps(ScheduleEntry first, ScheduleEntry second) => + first.DayOfWeek == second.DayOfWeek && + first.StartPeriod < second.StartPeriod + second.PeriodCount && + second.StartPeriod < first.StartPeriod + first.PeriodCount && + WeeksOverlap(first, second); + + public static string? ConflictReason(ScheduleEntry first, ScheduleEntry second) + { + if (first.ClassroomId == second.ClassroomId) return "教室"; + var firstTeachers = first.TeachingTask!.Teachers.Select(x => x.TeacherId).ToHashSet(); + if (second.TeachingTask!.Teachers.Any(x => firstTeachers.Contains(x.TeacherId))) + return "教师"; + var firstClasses = first.TeachingTask.Classes + .Select(x => x.AdministrativeClassId) + .ToHashSet(); + return second.TeachingTask.Classes.Any(x => + firstClasses.Contains(x.AdministrativeClassId)) + ? "行政班" + : null; + } + + private static bool WeeksOverlap(ScheduleEntry first, ScheduleEntry second) + { + var start = Math.Max(first.StartWeek, second.StartWeek); + var end = Math.Min(first.EndWeek, second.EndWeek); + for (var week = start; week <= end; week++) + { + if (IncludesWeek(first.WeekPattern, week) && + IncludesWeek(second.WeekPattern, week)) + return true; + } + return false; + } + + private static bool IncludesWeek(WeekPattern pattern, int week) => + pattern == WeekPattern.All || + pattern == WeekPattern.Odd && week % 2 == 1 || + pattern == WeekPattern.Even && week % 2 == 0; +} diff --git a/tests/Jiaowu.Api.Tests/PersistenceTests.cs b/tests/Jiaowu.Api.Tests/PersistenceTests.cs index f8f80aa..3ecb23d 100644 --- a/tests/Jiaowu.Api.Tests/PersistenceTests.cs +++ b/tests/Jiaowu.Api.Tests/PersistenceTests.cs @@ -118,6 +118,19 @@ public sealed class PersistenceTests : IAsyncLifetime EndDate = new DateOnly(2027, 1, 17), IsCurrent = true }; + var building = new Building + { + Code = "J1", + Name = "第一教学楼", + CampusId = campus.Id + }; + var classroom = new Classroom + { + Code = "J1-201", + Name = "J1-201", + BuildingId = building.Id, + Capacity = 60 + }; _db.AddRange( campus, college, @@ -134,7 +147,9 @@ public sealed class PersistenceTests : IAsyncLifetime Status = StudentStatus.Active }, course, - term); + term, + building, + classroom); await _db.SaveChangesAsync(); _db.CurriculumPlans.Add(new CurriculumPlan @@ -164,7 +179,7 @@ public sealed class PersistenceTests : IAsyncLifetime } ] }); - _db.TeachingTasks.Add(new TeachingTask + var teachingTask = new TeachingTask { TaskNumber = "2026-1-CS101-01", Name = "程序设计基础教学班", @@ -179,6 +194,27 @@ public sealed class PersistenceTests : IAsyncLifetime [ new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id } ] + }; + _db.TeachingTasks.Add(teachingTask); + _db.SchedulePlans.Add(new SchedulePlan + { + AcademicTermId = term.Id, + Name = "第一轮课表", + Version = "V1", + Entries = + [ + new ScheduleEntry + { + TeachingTaskId = teachingTask.Id, + ClassroomId = classroom.Id, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 16, + WeekPattern = WeekPattern.All + } + ] }); await _db.SaveChangesAsync(); @@ -192,10 +228,14 @@ public sealed class PersistenceTests : IAsyncLifetime .Include(x => x.Modules) .ThenInclude(x => x.Courses) .SingleAsync(); - var teachingTask = await _db.TeachingTasks + var savedTeachingTask = await _db.TeachingTasks .Include(x => x.Teachers) .Include(x => x.Classes) .SingleAsync(); + var schedulePlan = await _db.SchedulePlans + .Include(x => x.Entries) + .ThenInclude(x => x.Classroom) + .SingleAsync(); Assert.Equal("计算机学院", savedTeacher.College!.Name); Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name); @@ -203,7 +243,8 @@ public sealed class PersistenceTests : IAsyncLifetime Assert.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours); Assert.Single(curriculumPlan.Modules); Assert.Single(curriculumPlan.Modules.Single().Courses); - Assert.Single(teachingTask.Teachers); - Assert.Single(teachingTask.Classes); + Assert.Single(savedTeachingTask.Teachers); + Assert.Single(savedTeachingTask.Classes); + Assert.Equal("J1-201", schedulePlan.Entries.Single().Classroom!.Name); } } diff --git a/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs b/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs new file mode 100644 index 0000000..363bea6 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/ScheduleConflictTests.cs @@ -0,0 +1,81 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Scheduling; + +namespace Jiaowu.Api.Tests; + +public sealed class ScheduleConflictTests +{ + [Fact] + public void Same_classroom_and_overlapping_weeks_conflict() + { + var classroomId = Guid.NewGuid(); + var first = Entry(classroomId, WeekPattern.All, new TeachingTask + { + TaskNumber = "TASK-1", + Name = "教学班一" + }); + var second = Entry(classroomId, WeekPattern.All, new TeachingTask + { + TaskNumber = "TASK-2", + Name = "教学班二" + }); + + Assert.True(ScheduleConflictDetector.TimeOverlaps(first, second)); + Assert.Equal("教室", ScheduleConflictDetector.ConflictReason(first, second)); + } + + [Fact] + public void Odd_and_even_week_arrangements_do_not_overlap() + { + var first = Entry(Guid.NewGuid(), WeekPattern.Odd, new TeachingTask + { + TaskNumber = "TASK-1", + Name = "教学班一" + }); + var second = Entry(Guid.NewGuid(), WeekPattern.Even, new TeachingTask + { + TaskNumber = "TASK-2", + Name = "教学班二" + }); + + Assert.False(ScheduleConflictDetector.TimeOverlaps(first, second)); + } + + [Fact] + public void Shared_teacher_conflicts_across_different_classrooms() + { + var teacherId = Guid.NewGuid(); + var firstTask = new TeachingTask + { + TaskNumber = "TASK-1", + Name = "教学班一", + Teachers = [new TeachingTaskTeacher { TeacherId = teacherId }] + }; + var secondTask = new TeachingTask + { + TaskNumber = "TASK-2", + Name = "教学班二", + Teachers = [new TeachingTaskTeacher { TeacherId = teacherId }] + }; + var first = Entry(Guid.NewGuid(), WeekPattern.All, firstTask); + var second = Entry(Guid.NewGuid(), WeekPattern.All, secondTask); + + Assert.Equal("教师", ScheduleConflictDetector.ConflictReason(first, second)); + } + + private static ScheduleEntry Entry( + Guid classroomId, + WeekPattern weekPattern, + TeachingTask task) => + new() + { + ClassroomId = classroomId, + DayOfWeek = 1, + StartPeriod = 1, + PeriodCount = 2, + StartWeek = 1, + EndWeek = 16, + WeekPattern = weekPattern, + TeachingTask = task + }; +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 2e03d3e..e4fd0ac 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -8,6 +8,7 @@ import { Operation, Reading, Tickets, + Calendar, User, UserFilled, } from '@element-plus/icons-vue' @@ -27,6 +28,7 @@ const pageTitle = computed(() => { courses: '课程库', curriculum: '培养方案', 'teaching-tasks': '教学任务', + schedules: '排课与课表', users: '用户与权限', } return titles[String(route.name)] ?? '教务管理' @@ -98,6 +100,13 @@ onMounted(() => auth.refresh().catch(() => undefined)) + + + + @@ -106,7 +115,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
第一阶段 · 核心可用版 -

主数据与培养方案正在运行

+

主数据、培养方案与教学运行已就绪

diff --git a/web/src/router/index.ts b/web/src/router/index.ts index be64311..7da9a73 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -51,6 +51,12 @@ const router = createRouter({ component: () => import('../views/TeachingTasksView.vue'), meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] }, }, + { + path: 'schedules', + name: 'schedules', + component: () => import('../views/SchedulesView.vue'), + meta: { roles: ['SuperAdmin', 'AcademicAdmin'] }, + }, { path: 'users', name: 'users', diff --git a/web/src/style.css b/web/src/style.css index 87b7690..32e432e 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -205,6 +205,42 @@ button { cursor: pointer; } .task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; } .task-summary p { margin: 0; padding-left: 28px; border-left: 1px solid rgba(255,255,255,.16); color: #d4daeb; font-size: 11px; letter-spacing: .03em; } .muted-action { color: #9aa1ae; font-size: 11px; } +.schedule-toolbar { min-height: 86px; padding: 12px 16px; display: flex; align-items: stretch; gap: 14px; border: 1px solid var(--line); background: white; } +.schedule-toolbar > .el-select { width: 245px; align-self: center; } +.schedule-version-strip { min-width: 0; display: flex; align-items: stretch; gap: 8px; overflow-x: auto; } +.schedule-version-strip > button { min-width: 120px; padding: 10px 14px; display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; text-align: left; border: 1px solid var(--line); background: #fafbfc; } +.schedule-version-strip > button:hover { background: white; } +.schedule-version-strip > button.active { color: white; border-color: var(--indigo); background: var(--indigo); } +.schedule-version-strip b { font: 700 14px/1.2 Consolas, monospace; } +.schedule-version-strip span, .schedule-version-strip small { font-size: 9px; } +.schedule-version-strip small { grid-column: 1 / -1; color: #9199a8; } +.schedule-version-strip > button.active small { color: #cbd3eb; } +.schedule-version-strip > span { align-self: center; color: var(--muted); font-size: 11px; } +.schedule-sheet { min-width: 0; border: 1px solid var(--line); background: white; } +.schedule-sheet-head { padding: 21px 22px; display: flex; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); } +.schedule-sheet-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .07em; } +.schedule-sheet-head h3 { margin: 6px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 21px; } +.schedule-sheet-head p { margin: 0; color: var(--muted); font-size: 10px; } +.schedule-search { padding: 12px 16px; display: flex; align-items: center; gap: 10px; background: #fafbfc; border-bottom: 1px solid var(--line); } +.schedule-search .el-input { width: 320px; } +.schedule-search > span { margin-left: auto; color: var(--muted); font-size: 10px; } +.timetable-scroll { width: 100%; overflow: auto; } +.timetable-grid { min-width: 1120px; display: grid; grid-template-columns: 72px repeat(7, minmax(145px, 1fr)); } +.timetable-corner, .timetable-day { min-height: 45px; display: grid; place-items: center; color: #555f72; background: #f4f6f9; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); font-size: 11px; font-weight: 650; } +.timetable-period { min-height: 88px; padding: 14px 8px; display: grid; align-content: start; justify-items: center; gap: 5px; color: var(--indigo); background: #fafbfc; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.timetable-period b { font: 700 17px/1 Consolas, monospace; } +.timetable-period span { color: var(--muted); font-size: 9px; } +.timetable-cell { min-width: 0; min-height: 88px; padding: 5px; display: grid; align-content: start; gap: 5px; position: relative; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: white; } +.timetable-cell.editable:hover { background: #f7fbfa; } +.cell-add { margin: auto; color: transparent; font-size: 9px; } +.timetable-cell.editable:hover .cell-add { color: #a0a8b4; } +.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; } +.schedule-card.readonly { cursor: default; } +.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; } +.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; } +.schedule-card small, .schedule-card i { overflow: hidden; color: #c6cfe6; font-size: 8px; font-style: normal; white-space: nowrap; text-overflow: ellipsis; } +.schedule-card > button { position: absolute; right: 4px; top: 3px; border: none; color: #b9c4df; background: transparent; font-size: 14px; } +.schedule-card > button:hover { color: white; } .login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; } .login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; } @@ -288,6 +324,14 @@ button { cursor: pointer; } .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; } + .schedule-toolbar { display: block; } + .schedule-toolbar > .el-select { width: 100%; } + .schedule-version-strip { margin-top: 10px; } + .schedule-sheet-head { display: block; } + .schedule-sheet-head .plan-actions { margin-top: 15px; } + .schedule-search { flex-wrap: wrap; } + .schedule-search .el-input { width: 100%; } + .schedule-search > span { width: 100%; margin-left: 0; } .form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; } .el-dialog { width: calc(100vw - 24px) !important; } .login-page { display: block; min-height: 100vh; background: #f4f6f9; } diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 410f8d9..cf900ad 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -96,18 +96,23 @@ onMounted(async () => { {{ data.counts.teachingTasks ?? 0 }} 个教学班 · 教师与行政班 已建立 +
+ 排课课表 + {{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布 + 已建立 +
NEXT MILESTONE

下一段业务链

-

学期教学任务已可发布,下一步进入时间、教室冲突校验与课表发布。

+

排课与课表发布链路已就绪,下一步进入学生选课轮次、容量与候补管理。

基础底座 人员档案 培养方案 - 教学任务 + 排课课表
diff --git a/web/src/views/SchedulesView.vue b/web/src/views/SchedulesView.vue new file mode 100644 index 0000000..28a6ee5 --- /dev/null +++ b/web/src/views/SchedulesView.vue @@ -0,0 +1,365 @@ + + +