排课版本新建、编辑、复制、发布、归档和删除。

周课表视图,支持12节课、单双周和起止周。
自动校验教师、行政班、教室、时间段冲突。
校验教室容量、教学任务状态和学期范围。
SQLite 开发环境自动升级,MySQL 提供正式 EF Core 迁移。
Vue 已编译为静态文件并随 ASP.NET Core 发布,无需 npm run dev。
桌面端和390px移动端页面均已验收。
This commit is contained in:
2026-07-24 14:27:01 +08:00 Unverified
parent 628944f64d
commit 0b463fa4f2
19 changed files with 2950 additions and 9 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布
## 本地开发:热更新模式
+8
View File
@@ -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
@@ -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)
}
};
@@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<SchedulePlan?> DraftPlanAsync(
Guid id,
CancellationToken cancellationToken) =>
await db.SchedulePlans.FirstOrDefaultAsync(
x => x.Id == id && x.Status == SchedulePlanStatus.Draft,
cancellationToken);
private async Task<ActionResult?> 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<ActionResult> 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);
@@ -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<ScheduleEntry> 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
}
@@ -26,6 +26,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -221,6 +223,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<SchedulePlan>(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<ScheduleEntry>(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<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -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)
@@ -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");
"""
];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class Schedules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SchedulePlans",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
Version = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_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<Guid>(type: "char(36)", nullable: false),
SchedulePlanId = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
DayOfWeek = table.Column<int>(type: "int", nullable: false),
StartPeriod = table.Column<int>(type: "int", nullable: false),
PeriodCount = table.Column<int>(type: "int", nullable: false),
StartWeek = table.Column<int>(type: "int", nullable: false),
EndWeek = table.Column<int>(type: "int", nullable: false),
WeekPattern = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ScheduleEntries");
migrationBuilder.DropTable(
name: "SchedulePlans");
}
}
}
@@ -557,6 +557,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Majors");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("ClassroomId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("DayOfWeek")
.HasColumnType("int");
b.Property<int>("EndWeek")
.HasColumnType("int");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("PeriodCount")
.HasColumnType("int");
b.Property<Guid>("SchedulePlanId")
.HasColumnType("char(36)");
b.Property<int>("StartPeriod")
.HasColumnType("int");
b.Property<int>("StartWeek")
.HasColumnType("int");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("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<Guid>("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");
@@ -0,0 +1,62 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Scheduling;
public static class ScheduleConflictDetector
{
public static string? FindConflict(IReadOnlyList<ScheduleEntry> 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;
}
+46 -5
View File
@@ -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);
}
}
@@ -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
};
}
+10 -1
View File
@@ -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))
<el-icon><Tickets /></el-icon>
<template #title>教学任务</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role))"
index="/schedules"
>
<el-icon><Calendar /></el-icon>
<template #title>排课与课表</template>
</el-menu-item>
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
<el-icon><User /></el-icon>
<template #title>用户与权限</template>
@@ -106,7 +115,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
<div v-if="!collapsed" class="phase-note">
<span>第一阶段 · 核心可用版</span>
<p>主数据与培养方案正在运行</p>
<p>主数据培养方案与教学运行已就绪</p>
</div>
</aside>
+6
View File
@@ -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',
+44
View File
@@ -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; }
+7 -2
View File
@@ -96,18 +96,23 @@ onMounted(async () => {
<b>{{ data.counts.teachingTasks ?? 0 }} 个教学班 · 教师与行政班</b>
<i class="done">已建立</i>
</div>
<div>
<span>排课课表</span>
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
<i class="done">已建立</i>
</div>
</div>
</article>
<article class="work-card phase-card">
<span class="section-kicker">NEXT MILESTONE</span>
<h3>下一段业务链</h3>
<p>学期教学任务已可发布下一步进入时间教室冲突校验与课表发布</p>
<p>排课与课表发布链路已就绪下一步进入学生选课轮次容量与候补管理</p>
<div class="phase-line">
<span class="active">基础底座</span>
<span class="active">人员档案</span>
<span class="active">培养方案</span>
<span class="active">教学任务</span>
<span class="active">排课课表</span>
</div>
</article>
</section>
+365
View File
@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CopyDocument, Plus, Promotion, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const classrooms = ref<any[]>([])
const loading = ref(false)
const detailLoading = ref(false)
const planDialog = ref(false)
const cloneDialog = ref(false)
const entryDialog = ref(false)
const editingPlanId = ref('')
const editingEntryId = ref('')
const keyword = ref('')
const termId = ref<string | undefined>()
const planForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const entryForm = reactive<Record<string, any>>({})
const weekdays = [
{ value: 1, label: '星期一' },
{ value: 2, label: '星期二' },
{ value: 3, label: '星期三' },
{ value: 4, label: '星期四' },
{ value: 5, label: '星期五' },
{ value: 6, label: '星期六' },
{ value: 7, label: '星期日' },
]
const periods = Array.from({ length: 12 }, (_, index) => index + 1)
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Archived: '已归档',
}
const patternLabels: Record<string, string> = {
All: '每周',
Odd: '单周',
Even: '双周',
}
const isDraft = computed(() => selected.value?.status === 'Draft')
const filteredEntries = computed(() => {
const text = keyword.value.trim().toLowerCase()
if (!text) return selected.value?.entries ?? []
return (selected.value?.entries ?? []).filter((entry: any) =>
[
entry.taskNumber,
entry.taskName,
entry.courseCode,
entry.courseName,
entry.classroomName,
...entry.teacherNames,
...entry.classNames,
].some((value) => String(value).toLowerCase().includes(text)),
)
})
function entriesAt(day: number, period: number) {
return filteredEntries.value.filter(
(entry: any) => entry.dayOfWeek === day && entry.startPeriod === period,
)
}
async function loadPlans(keepSelection = true) {
loading.value = true
try {
plans.value = (await http.get('/schedules/plans', {
params: { academicTermId: termId.value },
})).data
const id = keepSelection && selected.value
? selected.value.id
: plans.value[0]?.id
if (id && plans.value.some((item) => item.id === id)) await loadDetail(id)
else selected.value = null
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadDetail(id: string) {
detailLoading.value = true
try {
selected.value = (await http.get(`/schedules/plans/${id}`)).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function openPlan(plan?: any) {
editingPlanId.value = plan?.id ?? ''
Object.assign(planForm, {
academicTermId: plan?.academicTermId ?? termId.value,
name: plan?.name ?? '',
version: plan?.version ?? `V${plans.value.length + 1}`,
notes: plan?.notes ?? '',
})
planDialog.value = true
}
async function savePlan() {
if (!planForm.academicTermId || !planForm.name?.trim() || !planForm.version?.trim()) {
ElMessage.warning('请填写学期、版本名称和版本号。')
return
}
try {
if (editingPlanId.value) {
await http.put(`/schedules/plans/${editingPlanId.value}`, planForm)
} else {
await http.post('/schedules/plans', planForm)
}
planDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openClone() {
Object.assign(cloneForm, {
name: `${selected.value.name}(调整版)`,
version: `V${plans.value.length + 1}`,
})
cloneDialog.value = true
}
async function clonePlan() {
try {
await http.post(`/schedules/plans/${selected.value.id}/clone`, cloneForm)
cloneDialog.value = false
ElMessage.success('已复制为可调整的草稿版本')
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm(
'系统将再次检查全部教师、行政班和教室冲突;发布后本版本锁定,并归档旧课表。',
'发布课表',
{ type: 'warning', confirmButtonText: '检查并发布', cancelButtonText: '取消' },
)
await http.post(`/schedules/plans/${selected.value.id}/publish`)
ElMessage.success('课表已发布')
await loadPlans()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function deletePlan() {
try {
await ElMessageBox.confirm('删除该草稿及全部排课条目?', '删除排课草稿', {
type: 'warning',
})
await http.delete(`/schedules/plans/${selected.value.id}`)
await loadPlans(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openEntry(entry?: any, day?: number, period?: number) {
editingEntryId.value = entry?.id ?? ''
Object.assign(entryForm, {
teachingTaskId: entry?.teachingTaskId,
classroomId: entry?.classroomId,
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
startPeriod: entry?.startPeriod ?? period ?? 1,
periodCount: entry?.periodCount ?? 2,
startWeek: entry?.startWeek ?? 1,
endWeek: entry?.endWeek ?? 16,
weekPattern: entry?.weekPattern ?? 'All',
notes: entry?.notes ?? '',
})
entryDialog.value = true
}
async function saveEntry() {
if (!entryForm.teachingTaskId || !entryForm.classroomId) {
ElMessage.warning('请选择教学任务和教室。')
return
}
try {
const base = `/schedules/plans/${selected.value.id}/entries`
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
else await http.post(base, entryForm)
entryDialog.value = false
ElMessage.success(editingEntryId.value ? '排课已调整' : '排课已添加')
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteEntry(entry: any) {
try {
await ElMessageBox.confirm(`移除“${entry.taskName}”的这条安排?`, '移除排课', {
type: 'warning',
})
await http.delete(`/schedules/plans/${selected.value.id}/entries/${entry.id}`)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [termRes, taskRes, classroomRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 100, status: 'Published' } }),
http.get('/base-data/classrooms'),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
termId.value = terms.value.find((item) => item.isCurrent)?.id
await loadPlans(false)
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">TIMETABLE BOARD</span>
<h2>排课与课表</h2>
<p>在发布前消除教师行政班与教室冲突并保留每次发布的版本</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建排课版本</el-button>
</section>
<section class="schedule-toolbar">
<el-select v-model="termId" placeholder="选择学期" @change="loadPlans(false)">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<div class="schedule-version-strip">
<button
v-for="plan in plans"
:key="plan.id"
type="button"
:class="{ active: selected?.id === plan.id }"
@click="loadDetail(plan.id)"
>
<b>{{ plan.version }}</b>
<span>{{ statusLabels[plan.status] }}</span>
<small>{{ plan.entryCount }} 条安排</small>
</button>
<span v-if="plans.length === 0">该学期还没有排课版本</span>
</div>
</section>
<section v-if="selected" v-loading="detailLoading" class="schedule-sheet">
<header class="schedule-sheet-head">
<div>
<span>{{ selected.termName }} · {{ selected.version }}</span>
<h3>{{ selected.name }}</h3>
<p> {{ selected.entries.length }} 条安排 · {{ statusLabels[selected.status] }}</p>
</div>
<div class="plan-actions">
<el-button v-if="isDraft" @click="openPlan(selected)">编辑版本</el-button>
<el-button :icon="CopyDocument" @click="openClone">复制调整</el-button>
<el-button v-if="isDraft" type="primary" :icon="Plus" @click="openEntry()">添加排课</el-button>
<el-button v-if="isDraft" type="success" :icon="Promotion" @click="publishPlan">发布课表</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除草稿</el-button>
</div>
</header>
<div class="schedule-search">
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="筛选课程、教师、行政班或教室" />
<el-button :icon="Refresh" @click="keyword = ''">清除筛选</el-button>
<span>单双周和周次范围显示在课程卡片内</span>
</div>
<div class="timetable-scroll">
<div class="timetable-grid">
<div class="timetable-corner">节次</div>
<div v-for="day in weekdays" :key="day.value" class="timetable-day">{{ day.label }}</div>
<template v-for="period in periods" :key="period">
<div class="timetable-period">
<b>{{ period }}</b>
<span> {{ period }} </span>
</div>
<div
v-for="day in weekdays"
:key="`${day.value}-${period}`"
class="timetable-cell"
:class="{ editable: isDraft }"
@dblclick="isDraft && openEntry(undefined, day.value, period)"
>
<article
v-for="entry in entriesAt(day.value, period)"
:key="entry.id"
class="schedule-card"
:class="{ readonly: !isDraft }"
@click="isDraft && openEntry(entry)"
>
<span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span>
<b>{{ entry.courseName }}</b>
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName }}</small>
<i>{{ entry.startWeek }}{{ entry.endWeek }} / 连上 {{ entry.periodCount }} </i>
<button v-if="isDraft" type="button" @click.stop="deleteEntry(entry)">×</button>
</article>
<span v-if="isDraft && entriesAt(day.value, period).length === 0" class="cell-add">双击添加</span>
</div>
</template>
</div>
</div>
</section>
<el-empty v-else description="请选择或新建一个排课版本" />
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px">
<el-form label-position="top">
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item>
<el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item>
<el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="planDialog = false">取消</el-button><el-button type="primary" @click="savePlan">保存</el-button></template>
</el-dialog>
<el-dialog v-model="cloneDialog" title="复制排课版本" width="520px">
<el-form label-position="top">
<el-form-item label="新版本名称" required><el-input v-model="cloneForm.name" /></el-form-item>
<el-form-item label="新版本号" required><el-input v-model="cloneForm.version" /></el-form-item>
</el-form>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">复制</el-button></template>
</el-dialog>
<el-dialog v-model="entryDialog" :title="editingEntryId ? '调整排课' : '添加排课'" width="680px">
<el-form label-position="top">
<el-form-item label="教学任务" required>
<el-select v-model="entryForm.teachingTaskId" filterable>
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="教室" required>
<el-select v-model="entryForm.classroomId" filterable>
<el-option v-for="item in classrooms" :key="item.id" :label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.capacity}人)`" :value="item.id" />
</el-select>
</el-form-item>
<div class="form-grid three">
<el-form-item label="星期"><el-select v-model="entryForm.dayOfWeek"><el-option v-for="day in weekdays" :key="day.value" :label="day.label" :value="day.value" /></el-select></el-form-item>
<el-form-item label="开始节次"><el-input-number v-model="entryForm.startPeriod" :min="1" :max="12" /></el-form-item>
<el-form-item label="连续节数"><el-input-number v-model="entryForm.periodCount" :min="1" :max="6" /></el-form-item>
</div>
<div class="form-grid three">
<el-form-item label="开始周"><el-input-number v-model="entryForm.startWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="结束周"><el-input-number v-model="entryForm.endWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="单双周"><el-select v-model="entryForm.weekPattern"><el-option v-for="(label, value) in patternLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="entryForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="entryDialog = false">取消</el-button><el-button type="primary" @click="saveEntry">检查冲突并保存</el-button></template>
</el-dialog>
</div>
</template>