排课版本新建、编辑、复制、发布、归档和删除。
周课表视图,支持12节课、单双周和起止周。 自动校验教师、行政班、教室、时间段冲突。 校验教室容量、教学任务状态和学期范围。 SQLite 开发环境自动升级,MySQL 提供正式 EF Core 迁移。 Vue 已编译为静态文件并随 ASP.NET Core 发布,无需 npm run dev。 桌面端和390px移动端页面均已验收。
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布。
|
||||||
|
|
||||||
## 本地开发:热更新模式
|
## 本地开发:热更新模式
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ try {
|
|||||||
-Headers $headers
|
-Headers $headers
|
||||||
}
|
}
|
||||||
$teachingTasks = Invoke-RestMethod -Uri 'http://localhost:5255/api/teaching-tasks?page=1&pageSize=10' -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
|
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
|
||||||
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
||||||
$unknownApiParameters = @{
|
$unknownApiParameters = @{
|
||||||
@@ -97,6 +103,8 @@ try {
|
|||||||
Plans = $curriculumPlans.total
|
Plans = $curriculumPlans.total
|
||||||
PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 }
|
PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 }
|
||||||
TeachingTasks = $teachingTasks.total
|
TeachingTasks = $teachingTasks.total
|
||||||
|
Schedules = @($schedulePlans).Count
|
||||||
|
ScheduleEntries = if ($null -ne $scheduleDetail) { @($scheduleDetail.entries).Count } else { 0 }
|
||||||
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
||||||
SpaFallback = $spaFallback.StatusCode
|
SpaFallback = $spaFallback.StatusCode
|
||||||
ApiNotFound = $unknownApi.StatusCode
|
ApiNotFound = $unknownApi.StatusCode
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
|||||||
Courses = await db.Courses.CountAsync(cancellationToken),
|
Courses = await db.Courses.CountAsync(cancellationToken),
|
||||||
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
|
||||||
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
|
||||||
|
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
|
||||||
Users = await db.Users.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<TeachingTask> TeachingTasks => Set<TeachingTask>();
|
||||||
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
|
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
|
||||||
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
|
||||||
|
public DbSet<SchedulePlan> SchedulePlans => Set<SchedulePlan>();
|
||||||
|
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
@@ -221,6 +223,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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 =>
|
builder.Entity<AuditLog>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Method).HasMaxLength(10);
|
entity.Property(x => x.Method).HasMaxLength(10);
|
||||||
|
|||||||
@@ -389,6 +389,36 @@ public sealed class DatabaseInitializer(
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
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)
|
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 PeopleAndCoursesMigration = "20260724_01_people_courses";
|
||||||
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
|
private const string CurriculumPlansMigration = "20260724_02_curriculum_plans";
|
||||||
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
|
private const string TeachingTasksMigration = "20260724_03_teaching_tasks";
|
||||||
|
private const string SchedulesMigration = "20260724_04_schedules";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,10 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
TeachingTasksMigration,
|
TeachingTasksMigration,
|
||||||
TeachingTasksStatements,
|
TeachingTasksStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
SchedulesMigration,
|
||||||
|
SchedulesStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -332,4 +337,66 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "TeachingTaskClasses" ("AdministrativeClassId");
|
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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1471
File diff suppressed because it is too large
Load Diff
+119
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -557,6 +557,105 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("Majors");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1159,6 +1258,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("College");
|
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 =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
|
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
|
||||||
@@ -1314,6 +1451,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Navigation("Modules");
|
b.Navigation("Modules");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Entries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Classes");
|
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;
|
||||||
|
}
|
||||||
@@ -118,6 +118,19 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
EndDate = new DateOnly(2027, 1, 17),
|
EndDate = new DateOnly(2027, 1, 17),
|
||||||
IsCurrent = true
|
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(
|
_db.AddRange(
|
||||||
campus,
|
campus,
|
||||||
college,
|
college,
|
||||||
@@ -134,7 +147,9 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
Status = StudentStatus.Active
|
Status = StudentStatus.Active
|
||||||
},
|
},
|
||||||
course,
|
course,
|
||||||
term);
|
term,
|
||||||
|
building,
|
||||||
|
classroom);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
_db.CurriculumPlans.Add(new CurriculumPlan
|
_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",
|
TaskNumber = "2026-1-CS101-01",
|
||||||
Name = "程序设计基础教学班",
|
Name = "程序设计基础教学班",
|
||||||
@@ -179,6 +194,27 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
[
|
[
|
||||||
new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id }
|
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();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -192,10 +228,14 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
.Include(x => x.Modules)
|
.Include(x => x.Modules)
|
||||||
.ThenInclude(x => x.Courses)
|
.ThenInclude(x => x.Courses)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
var teachingTask = await _db.TeachingTasks
|
var savedTeachingTask = await _db.TeachingTasks
|
||||||
.Include(x => x.Teachers)
|
.Include(x => x.Teachers)
|
||||||
.Include(x => x.Classes)
|
.Include(x => x.Classes)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
|
var schedulePlan = await _db.SchedulePlans
|
||||||
|
.Include(x => x.Entries)
|
||||||
|
.ThenInclude(x => x.Classroom)
|
||||||
|
.SingleAsync();
|
||||||
|
|
||||||
Assert.Equal("计算机学院", savedTeacher.College!.Name);
|
Assert.Equal("计算机学院", savedTeacher.College!.Name);
|
||||||
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.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.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours);
|
||||||
Assert.Single(curriculumPlan.Modules);
|
Assert.Single(curriculumPlan.Modules);
|
||||||
Assert.Single(curriculumPlan.Modules.Single().Courses);
|
Assert.Single(curriculumPlan.Modules.Single().Courses);
|
||||||
Assert.Single(teachingTask.Teachers);
|
Assert.Single(savedTeachingTask.Teachers);
|
||||||
Assert.Single(teachingTask.Classes);
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Operation,
|
Operation,
|
||||||
Reading,
|
Reading,
|
||||||
Tickets,
|
Tickets,
|
||||||
|
Calendar,
|
||||||
User,
|
User,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
@@ -27,6 +28,7 @@ const pageTitle = computed(() => {
|
|||||||
courses: '课程库',
|
courses: '课程库',
|
||||||
curriculum: '培养方案',
|
curriculum: '培养方案',
|
||||||
'teaching-tasks': '教学任务',
|
'teaching-tasks': '教学任务',
|
||||||
|
schedules: '排课与课表',
|
||||||
users: '用户与权限',
|
users: '用户与权限',
|
||||||
}
|
}
|
||||||
return titles[String(route.name)] ?? '教务管理'
|
return titles[String(route.name)] ?? '教务管理'
|
||||||
@@ -98,6 +100,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
<el-icon><Tickets /></el-icon>
|
<el-icon><Tickets /></el-icon>
|
||||||
<template #title>教学任务</template>
|
<template #title>教学任务</template>
|
||||||
</el-menu-item>
|
</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-menu-item v-if="auth.isSuperAdmin" index="/users">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<template #title>用户与权限</template>
|
<template #title>用户与权限</template>
|
||||||
@@ -106,7 +115,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
|||||||
|
|
||||||
<div v-if="!collapsed" class="phase-note">
|
<div v-if="!collapsed" class="phase-note">
|
||||||
<span>第一阶段 · 核心可用版</span>
|
<span>第一阶段 · 核心可用版</span>
|
||||||
<p>主数据与培养方案正在运行</p>
|
<p>主数据、培养方案与教学运行已就绪</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/TeachingTasksView.vue'),
|
component: () => import('../views/TeachingTasksView.vue'),
|
||||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'schedules',
|
||||||
|
name: 'schedules',
|
||||||
|
component: () => import('../views/SchedulesView.vue'),
|
||||||
|
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
name: 'users',
|
name: 'users',
|
||||||
|
|||||||
@@ -205,6 +205,42 @@ button { cursor: pointer; }
|
|||||||
.task-summary b { margin-left: auto; color: #58d1c0; font: 700 30px/1 Consolas, monospace; }
|
.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; }
|
.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; }
|
.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-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; }
|
.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 { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||||
.task-summary > div { width: 100%; }
|
.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; }
|
.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; }
|
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
||||||
.el-dialog { width: calc(100vw - 24px) !important; }
|
.el-dialog { width: calc(100vw - 24px) !important; }
|
||||||
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
|
.login-page { display: block; min-height: 100vh; background: #f4f6f9; }
|
||||||
|
|||||||
@@ -96,18 +96,23 @@ onMounted(async () => {
|
|||||||
<b>{{ data.counts.teachingTasks ?? 0 }} 个教学班 · 教师与行政班</b>
|
<b>{{ data.counts.teachingTasks ?? 0 }} 个教学班 · 教师与行政班</b>
|
||||||
<i class="done">已建立</i>
|
<i class="done">已建立</i>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>排课课表</span>
|
||||||
|
<b>{{ data.counts.schedulePlans ?? 0 }} 个版本 · 冲突校验与发布</b>
|
||||||
|
<i class="done">已建立</i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="work-card phase-card">
|
<article class="work-card phase-card">
|
||||||
<span class="section-kicker">NEXT MILESTONE</span>
|
<span class="section-kicker">NEXT MILESTONE</span>
|
||||||
<h3>下一段业务链</h3>
|
<h3>下一段业务链</h3>
|
||||||
<p>学期教学任务已可发布,下一步进入时间、教室冲突校验与课表发布。</p>
|
<p>排课与课表发布链路已就绪,下一步进入学生选课轮次、容量与候补管理。</p>
|
||||||
<div class="phase-line">
|
<div class="phase-line">
|
||||||
<span class="active">基础底座</span>
|
<span class="active">基础底座</span>
|
||||||
<span class="active">人员档案</span>
|
<span class="active">人员档案</span>
|
||||||
<span class="active">培养方案</span>
|
<span class="active">培养方案</span>
|
||||||
<span class="active">教学任务</span>
|
<span class="active">排课课表</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user