培养方案:专业/年级版本、课程模块、学分校验、复制版本、发布锁定、旧版本归档。

教学任务:学期开课、授课教师、唯一主讲、合班、容量与周次校验、发布及结课。
SQLite 开发库已自动升级,无需删除原数据库。
新增对应 MySQL EF Core 迁移。
Vue 已编译进 wwwroot,仍可只运行 ASP.NET Core 单服务。
桌面端及 390px 窄屏页面验证通过,无前端控制台错误。
This commit is contained in:
2026-07-24 14:10:43 +08:00 Unverified
parent 30d793773c
commit 628944f64d
23 changed files with 5430 additions and 47 deletions
+1 -1
View File
@@ -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。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查。 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课
## 本地开发:热更新模式 ## 本地开发:热更新模式
+10
View File
@@ -69,6 +69,13 @@ try {
$teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers $teachers = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/teachers?page=1&pageSize=10' -Headers $headers
$students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers $students = Invoke-RestMethod -Uri 'http://localhost:5255/api/personnel/students?page=1&pageSize=10' -Headers $headers
$courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers $courses = Invoke-RestMethod -Uri 'http://localhost:5255/api/courses?page=1&pageSize=10' -Headers $headers
$curriculumPlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/curriculum-plans?page=1&pageSize=10' -Headers $headers
if ($curriculumPlans.total -gt 0) {
$curriculumDetail = Invoke-RestMethod `
-Uri "http://localhost:5255/api/curriculum-plans/$($curriculumPlans.items[0].id)" `
-Headers $headers
}
$teachingTasks = Invoke-RestMethod -Uri 'http://localhost:5255/api/teaching-tasks?page=1&pageSize=10' -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 = @{
@@ -87,6 +94,9 @@ try {
Teachers = $teachers.total Teachers = $teachers.total
Students = $students.total Students = $students.total
Courses = $courses.total Courses = $courses.total
Plans = $curriculumPlans.total
PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 }
TeachingTasks = $teachingTasks.total
StaticIndex = $frontend.Content.Contains('明序教务管理系统') StaticIndex = $frontend.Content.Contains('明序教务管理系统')
SpaFallback = $spaFallback.StatusCode SpaFallback = $spaFallback.StatusCode
ApiNotFound = $unknownApi.StatusCode ApiNotFound = $unknownApi.StatusCode
@@ -0,0 +1,518 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ReadRoles)]
[Route("api/curriculum-plans")]
public sealed class CurriculumPlansController(AppDbContext db) : ControllerBase
{
private const string ReadRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet]
public async Task<ActionResult<PagedResult<object>>> GetPlans(
int page = 1,
int pageSize = 20,
string? keyword = null,
Guid? majorId = null,
int? effectiveGrade = null,
CurriculumPlanStatus? status = null,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = ScopedPlans().AsNoTracking();
if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId);
if (effectiveGrade.HasValue)
source = source.Where(x => x.EffectiveGrade == effectiveGrade);
if (status.HasValue) source = source.Where(x => x.Status == status);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.Name.Contains(keyword) ||
x.Version.Contains(keyword) ||
x.Major!.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.EffectiveGrade)
.ThenByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.Name,
x.Version,
x.MajorId,
MajorName = x.Major!.Name,
CollegeId = x.Major.CollegeId,
CollegeName = x.Major.College!.Name,
x.EffectiveGrade,
x.TotalCredits,
x.Status,
x.PublishedAt,
ModuleCount = x.Modules.Count,
CourseCount = x.Modules.SelectMany(module => module.Courses).Count(),
x.UpdatedAt
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
{
var plan = await ScopedPlans()
.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.Name,
x.Version,
x.MajorId,
MajorName = x.Major!.Name,
SchoolingYears = x.Major.SchoolingYears,
CollegeId = x.Major.CollegeId,
CollegeName = x.Major.College!.Name,
x.EffectiveGrade,
x.TotalCredits,
x.Status,
x.Description,
x.PublishedAt,
x.CreatedAt,
x.UpdatedAt,
Modules = x.Modules
.OrderBy(module => module.SortOrder)
.ThenBy(module => module.Code)
.Select(module => new
{
module.Id,
module.Code,
module.Name,
module.RequiredCredits,
module.SortOrder,
AssignedCredits = module.Courses.Sum(course => course.Course!.Credits),
Courses = module.Courses
.OrderBy(course => course.RecommendedSemester)
.ThenBy(course => course.Course!.Code)
.Select(course => new
{
course.Id,
course.CourseId,
CourseCode = course.Course!.Code,
CourseName = course.Course.Name,
course.Course.Credits,
course.Course.TotalHours,
course.Course.Nature,
course.RecommendedSemester,
course.Type,
course.Notes
})
})
})
.FirstOrDefaultAsync(cancellationToken);
return plan is null ? NotFound() : Ok(plan);
}
[HttpPost]
public async Task<ActionResult> Create(
CurriculumPlanRequest request,
CancellationToken cancellationToken)
{
var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken);
if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。");
var plan = new CurriculumPlan
{
MajorId = request.MajorId,
Name = request.Name.Trim(),
Version = request.Version.Trim(),
EffectiveGrade = request.EffectiveGrade,
TotalCredits = request.TotalCredits,
Description = Normalize(request.Description)
};
db.CurriculumPlans.Add(plan);
return await SaveCreatedAsync(plan.Id, cancellationToken);
}
[HttpPut("{id:guid}")]
public async Task<ActionResult> Update(
Guid id,
CurriculumPlanRequest request,
CancellationToken cancellationToken)
{
var plan = await EditablePlanAsync(id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("已发布或已归档的培养方案不可直接修改,请复制为新版本。");
var major = await FindAccessibleMajorAsync(request.MajorId, cancellationToken);
if (major is null) return ValidationProblem("所选专业不存在或不在当前数据范围内。");
plan.MajorId = request.MajorId;
plan.Name = request.Name.Trim();
plan.Version = request.Version.Trim();
plan.EffectiveGrade = request.EffectiveGrade;
plan.TotalCredits = request.TotalCredits;
plan.Description = Normalize(request.Description);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var plan = await EditablePlanAsync(id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("仅草稿方案可以删除。");
db.CurriculumPlans.Remove(plan);
return await SaveNoContentAsync(cancellationToken);
}
[HttpPost("{id:guid}/clone")]
public async Task<ActionResult> Clone(
Guid id,
CloneCurriculumPlanRequest request,
CancellationToken cancellationToken)
{
var source = await ScopedPlans()
.AsNoTracking()
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (source is null) return NotFound();
var copy = new CurriculumPlan
{
MajorId = source.MajorId,
Name = request.Name.Trim(),
Version = request.Version.Trim(),
EffectiveGrade = request.EffectiveGrade,
TotalCredits = source.TotalCredits,
Description = source.Description,
Modules = source.Modules.Select(module => new CurriculumModule
{
Code = module.Code,
Name = module.Name,
RequiredCredits = module.RequiredCredits,
SortOrder = module.SortOrder,
Courses = module.Courses.Select(course => new CurriculumCourse
{
CourseId = course.CourseId,
RecommendedSemester = course.RecommendedSemester,
Type = course.Type,
Notes = course.Notes
}).ToList()
}).ToList()
};
db.CurriculumPlans.Add(copy);
return await SaveCreatedAsync(copy.Id, cancellationToken);
}
[HttpPost("{id:guid}/publish")]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var plan = await ScopedPlans()
.Include(x => x.Major)
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (plan is null) return NotFound();
if (plan.Status != CurriculumPlanStatus.Draft)
return ConflictProblem("只有草稿方案可以发布。");
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0))
return ConflictProblem("发布前每个课程模块都必须配置课程。");
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
if (plan.Modules.Any(module =>
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits))
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。");
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1))
return ConflictProblem("同一门课程不能重复加入多个模块。");
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
x.RecommendedSemester > plan.Major!.SchoolingYears * 2))
return ConflictProblem("建议学期超出了该专业学制。");
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var previousPlans = await ScopedPlans()
.Where(x =>
x.Id != plan.Id &&
x.MajorId == plan.MajorId &&
x.EffectiveGrade == plan.EffectiveGrade &&
x.Status == CurriculumPlanStatus.Published)
.ToListAsync(cancellationToken);
foreach (var previous in previousPlans)
{
previous.Status = CurriculumPlanStatus.Archived;
}
plan.Status = CurriculumPlanStatus.Published;
plan.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
}
[HttpPost("{planId:guid}/modules")]
public async Task<ActionResult> CreateModule(
Guid planId,
CurriculumModuleRequest request,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var module = new CurriculumModule
{
CurriculumPlanId = plan.Id,
Code = request.Code.Trim(),
Name = request.Name.Trim(),
RequiredCredits = request.RequiredCredits,
SortOrder = request.SortOrder
};
db.CurriculumModules.Add(module);
return await SaveCreatedAsync(module.Id, cancellationToken);
}
[HttpPut("{planId:guid}/modules/{moduleId:guid}")]
public async Task<ActionResult> UpdateModule(
Guid planId,
Guid moduleId,
CurriculumModuleRequest request,
CancellationToken cancellationToken)
{
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var module = await db.CurriculumModules
.FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken);
if (module is null) return NotFound();
module.Code = request.Code.Trim();
module.Name = request.Name.Trim();
module.RequiredCredits = request.RequiredCredits;
module.SortOrder = request.SortOrder;
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{planId:guid}/modules/{moduleId:guid}")]
public async Task<ActionResult> DeleteModule(
Guid planId,
Guid moduleId,
CancellationToken cancellationToken)
{
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var module = await db.CurriculumModules
.FirstOrDefaultAsync(x => x.Id == moduleId && x.CurriculumPlanId == planId, cancellationToken);
if (module is null) return NotFound();
db.CurriculumModules.Remove(module);
return await SaveNoContentAsync(cancellationToken);
}
[HttpPost("{planId:guid}/modules/{moduleId:guid}/courses")]
public async Task<ActionResult> AddCourse(
Guid planId,
Guid moduleId,
CurriculumCourseRequest request,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
if (!await db.CurriculumModules.AnyAsync(
x => x.Id == moduleId && x.CurriculumPlanId == planId,
cancellationToken))
return NotFound();
if (!await db.Courses.AnyAsync(
x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
return ValidationProblem("建议学期超出了该专业学制。");
if (await db.CurriculumCourses.AnyAsync(
x => x.CurriculumModule!.CurriculumPlanId == planId &&
x.CourseId == request.CourseId,
cancellationToken))
return ConflictProblem("该课程已存在于本方案中。");
var item = new CurriculumCourse
{
CurriculumModuleId = moduleId,
CourseId = request.CourseId,
RecommendedSemester = request.RecommendedSemester,
Type = request.Type,
Notes = Normalize(request.Notes)
};
db.CurriculumCourses.Add(item);
return await SaveCreatedAsync(item.Id, cancellationToken);
}
[HttpPut("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
public async Task<ActionResult> UpdateCourse(
Guid planId,
Guid moduleId,
Guid itemId,
CurriculumCourseRequest request,
CancellationToken cancellationToken)
{
var plan = await DraftPlanAsync(planId, cancellationToken);
if (plan is null) return NotFound();
var item = await db.CurriculumCourses
.FirstOrDefaultAsync(x =>
x.Id == itemId &&
x.CurriculumModuleId == moduleId &&
x.CurriculumModule!.CurriculumPlanId == planId,
cancellationToken);
if (item is null) return NotFound();
if (!await db.Courses.AnyAsync(
x => x.Id == request.CourseId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选课程不存在或已停用。");
if (request.RecommendedSemester > plan.Major!.SchoolingYears * 2)
return ValidationProblem("建议学期超出了该专业学制。");
if (await db.CurriculumCourses.AnyAsync(
x => x.Id != itemId &&
x.CurriculumModule!.CurriculumPlanId == planId &&
x.CourseId == request.CourseId,
cancellationToken))
return ConflictProblem("该课程已存在于本方案中。");
item.CourseId = request.CourseId;
item.RecommendedSemester = request.RecommendedSemester;
item.Type = request.Type;
item.Notes = Normalize(request.Notes);
return await SaveNoContentAsync(cancellationToken);
}
[HttpDelete("{planId:guid}/modules/{moduleId:guid}/courses/{itemId:guid}")]
public async Task<ActionResult> DeleteCourse(
Guid planId,
Guid moduleId,
Guid itemId,
CancellationToken cancellationToken)
{
if (await DraftPlanAsync(planId, cancellationToken) is null) return NotFound();
var item = await db.CurriculumCourses
.FirstOrDefaultAsync(x =>
x.Id == itemId &&
x.CurriculumModuleId == moduleId &&
x.CurriculumModule!.CurriculumPlanId == planId,
cancellationToken);
if (item is null) return NotFound();
db.CurriculumCourses.Remove(item);
return await SaveNoContentAsync(cancellationToken);
}
private IQueryable<CurriculumPlan> ScopedPlans()
{
var source = db.CurriculumPlans.AsQueryable();
var collegeId = ScopedCollegeId();
return collegeId.HasValue
? source.Where(x => x.Major!.CollegeId == collegeId.Value)
: source;
}
private async Task<Major?> FindAccessibleMajorAsync(Guid majorId, CancellationToken cancellationToken)
{
var source = db.Majors.AsQueryable();
var collegeId = ScopedCollegeId();
if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value);
return await source.FirstOrDefaultAsync(x => x.Id == majorId, cancellationToken);
}
private async Task<CurriculumPlan?> EditablePlanAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedPlans().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private async Task<CurriculumPlan?> DraftPlanAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedPlans()
.Include(x => x.Major)
.FirstOrDefaultAsync(
x => x.Id == id && x.Status == CurriculumPlanStatus.Draft,
cancellationToken);
private Guid? ScopedCollegeId()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
}
private async Task<ActionResult> SaveCreatedAsync(Guid id, CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { id });
}
catch (DbUpdateException)
{
return ConflictProblem("版本、模块编码或课程配置重复。");
}
}
private async Task<ActionResult> SaveNoContentAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("数据重复,或记录已被其他业务引用。");
}
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record CurriculumPlanRequest(
Guid MajorId,
[Required, MaxLength(120)] string Name,
[Required, MaxLength(30)] string Version,
[Range(2000, 2200)] int EffectiveGrade,
[Range(typeof(decimal), "0.1", "999")] decimal TotalCredits,
[MaxLength(1000)] string? Description);
public sealed record CloneCurriculumPlanRequest(
[Required, MaxLength(120)] string Name,
[Required, MaxLength(30)] string Version,
[Range(2000, 2200)] int EffectiveGrade);
public sealed record CurriculumModuleRequest(
[Required, MaxLength(30)] string Code,
[Required, MaxLength(100)] string Name,
[Range(typeof(decimal), "0", "999")] decimal RequiredCredits,
[Range(0, 9999)] int SortOrder);
public sealed record CurriculumCourseRequest(
Guid CourseId,
[Range(1, 20)] int RecommendedSemester,
CurriculumCourseType Type,
[MaxLength(500)] string? Notes);
@@ -32,6 +32,8 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
Teachers = await db.Teachers.CountAsync(cancellationToken), Teachers = await db.Teachers.CountAsync(cancellationToken),
Students = await db.Students.CountAsync(cancellationToken), Students = await db.Students.CountAsync(cancellationToken),
Courses = await db.Courses.CountAsync(cancellationToken), Courses = await db.Courses.CountAsync(cancellationToken),
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
Users = await db.Users.CountAsync(cancellationToken) Users = await db.Users.CountAsync(cancellationToken)
} }
}; };
@@ -0,0 +1,364 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = ManagementRoles)]
[Route("api/teaching-tasks")]
public sealed class TeachingTasksController(AppDbContext db) : ControllerBase
{
private const string ManagementRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
[HttpGet]
public async Task<ActionResult<PagedResult<object>>> Get(
int page = 1,
int pageSize = 20,
string? keyword = null,
Guid? academicTermId = null,
Guid? collegeId = null,
TeachingTaskStatus? status = null,
CancellationToken cancellationToken = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = ScopedTasks().AsNoTracking();
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
if (collegeId.HasValue)
source = source.Where(x => x.Course!.CollegeId == collegeId);
if (status.HasValue) source = source.Where(x => x.Status == status);
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
x.TaskNumber.Contains(keyword) ||
x.Name.Contains(keyword) ||
x.Course!.Code.Contains(keyword) ||
x.Course.Name.Contains(keyword));
}
var total = await source.CountAsync(cancellationToken);
var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.CourseId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name,
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.Status,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
StudentCount = x.Classes.Sum(item =>
item.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active)),
x.UpdatedAt
})
.ToListAsync(cancellationToken);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult> GetOne(Guid id, CancellationToken cancellationToken)
{
var task = await ScopedTasks()
.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
x.CourseId,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CollegeId = x.Course.CollegeId,
CollegeName = x.Course.College!.Name,
x.Capacity,
x.StartWeek,
x.EndWeek,
x.WeeklyHours,
x.Status,
x.Notes,
x.PublishedAt,
TeacherIds = x.Teachers.Select(item => item.TeacherId),
PrimaryTeacherId = x.Teachers
.Where(item => item.IsPrimary)
.Select(item => (Guid?)item.TeacherId)
.FirstOrDefault(),
Teachers = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.ThenBy(item => item.Teacher!.TeacherNumber)
.Select(item => new
{
item.TeacherId,
item.Teacher!.TeacherNumber,
item.Teacher.Name,
item.Teacher.Title,
item.IsPrimary
}),
ClassIds = x.Classes.Select(item => item.AdministrativeClassId),
Classes = x.Classes
.OrderBy(item => item.AdministrativeClass!.Code)
.Select(item => new
{
item.AdministrativeClassId,
item.AdministrativeClass!.Code,
item.AdministrativeClass.Name,
item.AdministrativeClass.Grade,
StudentCount = item.AdministrativeClass.Students.Count(student =>
student.Status == StudentStatus.Active)
}),
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
return task is null ? NotFound() : Ok(task);
}
[HttpPost]
public async Task<ActionResult> Create(
TeachingTaskRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateRequestAsync(request, cancellationToken);
if (validation is not null) return validation;
var task = new TeachingTask
{
TaskNumber = request.TaskNumber.Trim(),
Name = request.Name.Trim(),
AcademicTermId = request.AcademicTermId,
CourseId = request.CourseId,
Capacity = request.Capacity,
StartWeek = request.StartWeek,
EndWeek = request.EndWeek,
WeeklyHours = request.WeeklyHours,
Notes = Normalize(request.Notes)
};
SetAssignments(task, request);
db.TeachingTasks.Add(task);
return await SaveAsync(task.Id, true, cancellationToken);
}
[HttpPut("{id:guid}")]
public async Task<ActionResult> Update(
Guid id,
TeachingTaskRequest request,
CancellationToken cancellationToken)
{
var task = await ScopedTasks()
.Include(x => x.Teachers)
.Include(x => x.Classes)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound();
if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("已发布或已结课的教学任务不可直接修改。");
var validation = await ValidateRequestAsync(request, cancellationToken);
if (validation is not null) return validation;
task.TaskNumber = request.TaskNumber.Trim();
task.Name = request.Name.Trim();
task.AcademicTermId = request.AcademicTermId;
task.CourseId = request.CourseId;
task.Capacity = request.Capacity;
task.StartWeek = request.StartWeek;
task.EndWeek = request.EndWeek;
task.WeeklyHours = request.WeeklyHours;
task.Notes = Normalize(request.Notes);
db.TeachingTaskTeachers.RemoveRange(task.Teachers);
db.TeachingTaskClasses.RemoveRange(task.Classes);
task.Teachers = [];
task.Classes = [];
SetAssignments(task, request);
return await SaveAsync(task.Id, false, cancellationToken);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound();
if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("仅草稿教学任务可以删除。");
db.TeachingTasks.Remove(task);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("{id:guid}/publish")]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var task = await ScopedTasks()
.Include(x => x.Teachers)
.Include(x => x.Classes)
.ThenInclude(x => x.AdministrativeClass)
.ThenInclude(x => x!.Students)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound();
if (task.Status != TeachingTaskStatus.Draft)
return ConflictProblem("只有草稿教学任务可以发布。");
if (task.Teachers.Count == 0 || task.Teachers.Count(x => x.IsPrimary) != 1)
return ConflictProblem("发布前必须指定且只能指定一名主讲教师。");
if (task.Classes.Count == 0)
return ConflictProblem("发布前至少需要关联一个行政班。");
var studentCount = task.Classes.Sum(x =>
x.AdministrativeClass!.Students.Count(student =>
student.Status == StudentStatus.Active));
if (studentCount > task.Capacity)
return ConflictProblem($"教学班容量不足:关联班级共有 {studentCount} 名在籍学生。");
task.Status = TeachingTaskStatus.Published;
task.PublishedAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("{id:guid}/close")]
public async Task<ActionResult> Close(Guid id, CancellationToken cancellationToken)
{
var task = await ScopedTasks().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (task is null) return NotFound();
if (task.Status != TeachingTaskStatus.Published)
return ConflictProblem("只有已发布的教学任务可以结课。");
task.Status = TeachingTaskStatus.Closed;
return await SaveAsync(id, false, cancellationToken);
}
private IQueryable<TeachingTask> ScopedTasks()
{
var source = db.TeachingTasks.AsQueryable();
var collegeId = ScopedCollegeId();
return collegeId.HasValue
? source.Where(x => x.Course!.CollegeId == collegeId.Value)
: source;
}
private async Task<ActionResult?> ValidateRequestAsync(
TeachingTaskRequest request,
CancellationToken cancellationToken)
{
if (request.StartWeek > request.EndWeek)
return ValidationProblem("开始周不能晚于结束周。");
var course = await db.Courses.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.CourseId && x.IsEnabled, cancellationToken);
if (course is null) return ValidationProblem("所选课程不存在或已停用。");
var collegeId = ScopedCollegeId();
if (collegeId.HasValue && course.CollegeId != collegeId.Value) return Forbid();
if (!await db.AcademicTerms.AnyAsync(
x => x.Id == request.AcademicTermId && x.IsEnabled,
cancellationToken))
return ValidationProblem("所选学期不存在或已停用。");
var teacherIds = request.TeacherIds.Distinct().ToArray();
if (request.PrimaryTeacherId.HasValue &&
!teacherIds.Contains(request.PrimaryTeacherId.Value))
return ValidationProblem("主讲教师必须包含在授课教师中。");
if (await db.Teachers.CountAsync(
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
cancellationToken) != teacherIds.Length)
return ValidationProblem("存在无效或非在职授课教师。");
var classIds = request.ClassIds.Distinct().ToArray();
var classes = db.AdministrativeClasses.AsNoTracking()
.Where(x => classIds.Contains(x.Id));
if (collegeId.HasValue)
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
if (await classes.CountAsync(cancellationToken) != classIds.Length)
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
return null;
}
private static void SetAssignments(TeachingTask task, TeachingTaskRequest request)
{
foreach (var teacherId in request.TeacherIds.Distinct())
{
task.Teachers.Add(new TeachingTaskTeacher
{
TeacherId = teacherId,
IsPrimary = teacherId == request.PrimaryTeacherId
});
}
foreach (var classId in request.ClassIds.Distinct())
{
task.Classes.Add(new TeachingTaskClass
{
AdministrativeClassId = classId
});
}
}
private Guid? ScopedCollegeId()
{
if (!User.IsInRole(SystemRoles.CollegeAdmin)) return null;
return Guid.TryParse(User.FindFirstValue("college_id"), out var collegeId)
? collegeId
: Guid.Empty;
}
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 TeachingTaskRequest(
[Required, MaxLength(40)] string TaskNumber,
[Required, MaxLength(120)] string Name,
Guid AcademicTermId,
Guid CourseId,
[Range(1, 10000)] int Capacity,
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,
[Range(1, 40)] int WeeklyHours,
IReadOnlyCollection<Guid> TeacherIds,
Guid? PrimaryTeacherId,
IReadOnlyCollection<Guid> ClassIds,
[MaxLength(500)] string? Notes);
@@ -0,0 +1,52 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class CurriculumPlan : EntityBase
{
public Guid MajorId { get; set; }
public Major? Major { get; set; }
public required string Name { get; set; }
public required string Version { get; set; }
public int EffectiveGrade { get; set; }
public decimal TotalCredits { get; set; }
public CurriculumPlanStatus Status { get; set; } = CurriculumPlanStatus.Draft;
public string? Description { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<CurriculumModule> Modules { get; set; } = [];
}
public sealed class CurriculumModule : EntityBase
{
public Guid CurriculumPlanId { get; set; }
public CurriculumPlan? CurriculumPlan { get; set; }
public required string Code { get; set; }
public required string Name { get; set; }
public decimal RequiredCredits { get; set; }
public int SortOrder { get; set; }
public ICollection<CurriculumCourse> Courses { get; set; } = [];
}
public sealed class CurriculumCourse : EntityBase
{
public Guid CurriculumModuleId { get; set; }
public CurriculumModule? CurriculumModule { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public int RecommendedSemester { get; set; }
public CurriculumCourseType Type { get; set; }
public string? Notes { get; set; }
}
public enum CurriculumPlanStatus
{
Draft = 1,
Published = 2,
Archived = 3
}
public enum CurriculumCourseType
{
Required = 1,
Elective = 2
}
@@ -28,6 +28,7 @@ public sealed class AdministrativeClass : CatalogEntity
public Major? Major { get; set; } public Major? Major { get; set; }
public int Grade { get; set; } public int Grade { get; set; }
public string? CounselorName { get; set; } public string? CounselorName { get; set; }
public ICollection<Student> Students { get; set; } = [];
} }
public sealed class Building : CatalogEntity public sealed class Building : CatalogEntity
@@ -0,0 +1,46 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class TeachingTask : EntityBase
{
public required string TaskNumber { get; set; }
public required string Name { get; set; }
public Guid AcademicTermId { get; set; }
public AcademicTerm? AcademicTerm { get; set; }
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public int Capacity { get; set; }
public int StartWeek { get; set; } = 1;
public int EndWeek { get; set; } = 16;
public int WeeklyHours { get; set; } = 2;
public TeachingTaskStatus Status { get; set; } = TeachingTaskStatus.Draft;
public string? Notes { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<TeachingTaskTeacher> Teachers { get; set; } = [];
public ICollection<TeachingTaskClass> Classes { get; set; } = [];
}
public sealed class TeachingTaskTeacher
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid TeacherId { get; set; }
public Teacher? Teacher { get; set; }
public bool IsPrimary { get; set; }
}
public sealed class TeachingTaskClass
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public Guid AdministrativeClassId { get; set; }
public AdministrativeClass? AdministrativeClass { get; set; }
}
public enum TeachingTaskStatus
{
Draft = 1,
Published = 2,
Closed = 3
}
@@ -20,6 +20,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Teacher> Teachers => Set<Teacher>(); public DbSet<Teacher> Teachers => Set<Teacher>();
public DbSet<Student> Students => Set<Student>(); public DbSet<Student> Students => Set<Student>();
public DbSet<Course> Courses => Set<Course>(); public DbSet<Course> Courses => Set<Course>();
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
public DbSet<TeachingTask> TeachingTasks => Set<TeachingTask>();
public DbSet<TeachingTaskTeacher> TeachingTaskTeachers => Set<TeachingTaskTeacher>();
public DbSet<TeachingTaskClass> TeachingTaskClasses => Set<TeachingTaskClass>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@@ -111,7 +117,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.HasIndex(x => new { x.AdministrativeClassId, x.Status }); entity.HasIndex(x => new { x.AdministrativeClassId, x.Status });
entity.HasIndex(x => x.EnrollmentYear); entity.HasIndex(x => x.EnrollmentYear);
entity.HasOne(x => x.AdministrativeClass) entity.HasOne(x => x.AdministrativeClass)
.WithMany() .WithMany(x => x.Students)
.HasForeignKey(x => x.AdministrativeClassId) .HasForeignKey(x => x.AdministrativeClassId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
entity.HasOne<ApplicationUser>() entity.HasOne<ApplicationUser>()
@@ -132,6 +138,89 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<CurriculumPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Version).HasMaxLength(30);
entity.Property(x => x.TotalCredits).HasPrecision(6, 2);
entity.Property(x => x.Description).HasMaxLength(1000);
entity.HasIndex(x => new { x.MajorId, x.EffectiveGrade, x.Version }).IsUnique();
entity.HasIndex(x => new { x.Status, x.EffectiveGrade });
entity.HasOne(x => x.Major)
.WithMany()
.HasForeignKey(x => x.MajorId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CurriculumModule>(entity =>
{
entity.Property(x => x.Code).HasMaxLength(30);
entity.Property(x => x.Name).HasMaxLength(100);
entity.Property(x => x.RequiredCredits).HasPrecision(6, 2);
entity.HasIndex(x => new { x.CurriculumPlanId, x.Code }).IsUnique();
entity.HasOne(x => x.CurriculumPlan)
.WithMany(x => x.Modules)
.HasForeignKey(x => x.CurriculumPlanId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CurriculumCourse>(entity =>
{
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.CurriculumModuleId, x.CourseId }).IsUnique();
entity.HasOne(x => x.CurriculumModule)
.WithMany(x => x.Courses)
.HasForeignKey(x => x.CurriculumModuleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTask>(entity =>
{
entity.Property(x => x.TaskNumber).HasMaxLength(40);
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => x.TaskNumber).IsUnique();
entity.HasIndex(x => new { x.AcademicTermId, x.Status });
entity.HasOne(x => x.AcademicTerm)
.WithMany()
.HasForeignKey(x => x.AcademicTermId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.Course)
.WithMany()
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskTeacher>(entity =>
{
entity.HasKey(x => new { x.TeachingTaskId, x.TeacherId });
entity.HasOne(x => x.TeachingTask)
.WithMany(x => x.Teachers)
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Teacher)
.WithMany()
.HasForeignKey(x => x.TeacherId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<TeachingTaskClass>(entity =>
{
entity.HasKey(x => new { x.TeachingTaskId, x.AdministrativeClassId });
entity.HasOne(x => x.TeachingTask)
.WithMany(x => x.Classes)
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.AdministrativeClass)
.WithMany()
.HasForeignKey(x => x.AdministrativeClassId)
.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);
@@ -288,6 +288,107 @@ public sealed class DatabaseInitializer(
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
if (!await db.CurriculumPlans.AnyAsync())
{
var seededCourses = await db.Courses
.Where(x => x.Code == "CS101" || x.Code == "CS201" || x.Code == "CS305")
.ToDictionaryAsync(x => x.Code);
if (seededCourses.Count == 3)
{
db.CurriculumPlans.Add(new CurriculumPlan
{
MajorId = (await db.Majors.SingleAsync(x => x.Code == "080901")).Id,
Name = "计算机科学与技术专业培养方案",
Version = "2026版",
EffectiveGrade = 2026,
TotalCredits = 9.5m,
Status = CurriculumPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Description = "用于本地开发的精简培养方案示例。",
Modules =
[
new CurriculumModule
{
Code = "BASIC",
Name = "专业基础课程",
RequiredCredits = 7.5m,
SortOrder = 10,
Courses =
[
new CurriculumCourse
{
CourseId = seededCourses["CS101"].Id,
RecommendedSemester = 1,
Type = CurriculumCourseType.Required
},
new CurriculumCourse
{
CourseId = seededCourses["CS201"].Id,
RecommendedSemester = 3,
Type = CurriculumCourseType.Required
}
]
},
new CurriculumModule
{
Code = "PRACTICE",
Name = "实践教学",
RequiredCredits = 2m,
SortOrder = 20,
Courses =
[
new CurriculumCourse
{
CourseId = seededCourses["CS305"].Id,
RecommendedSemester = 5,
Type = CurriculumCourseType.Required
}
]
}
]
});
await db.SaveChangesAsync();
}
}
if (!await db.TeachingTasks.AnyAsync())
{
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var course = await db.Courses.SingleAsync(x => x.Code == "CS101");
var teacher = await db.Teachers.SingleAsync(x => x.TeacherNumber == "T2026001");
var administrativeClass = await db.AdministrativeClasses
.SingleAsync(x => x.Code == "CS2026-01");
db.TeachingTasks.Add(new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班 01",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
StartWeek = 1,
EndWeek = 16,
WeeklyHours = 4,
Status = TeachingTaskStatus.Published,
PublishedAt = DateTime.UtcNow,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
});
await db.SaveChangesAsync();
}
} }
private static void EnsureSucceeded(IdentityResult result, string action) private static void EnsureSucceeded(IdentityResult result, string action)
@@ -7,6 +7,8 @@ public sealed class DevelopmentSqliteMigrator(
ILogger<DevelopmentSqliteMigrator> logger) ILogger<DevelopmentSqliteMigrator> logger)
{ {
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 TeachingTasksMigration = "20260724_03_teaching_tasks";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -24,12 +26,31 @@ public sealed class DevelopmentSqliteMigrator(
""", """,
cancellationToken); cancellationToken);
await ApplyMigrationAsync(
PeopleAndCoursesMigration,
PeopleAndCoursesStatements,
cancellationToken);
await ApplyMigrationAsync(
CurriculumPlansMigration,
CurriculumPlansStatements,
cancellationToken);
await ApplyMigrationAsync(
TeachingTasksMigration,
TeachingTasksStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
string migrationId,
IEnumerable<string> statements,
CancellationToken cancellationToken)
{
var applied = await db.Database var applied = await db.Database
.SqlQuery<string>( .SqlQuery<string>(
$""" $"""
SELECT "MigrationId" AS "Value" SELECT "MigrationId" AS "Value"
FROM "__DevelopmentSchemaHistory" FROM "__DevelopmentSchemaHistory"
WHERE "MigrationId" = {PeopleAndCoursesMigration} WHERE "MigrationId" = {migrationId}
""") """)
.AnyAsync(cancellationToken); .AnyAsync(cancellationToken);
if (applied) if (applied)
@@ -38,7 +59,7 @@ public sealed class DevelopmentSqliteMigrator(
} }
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken); await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
foreach (var statement in PeopleAndCoursesStatements) foreach (var statement in statements)
{ {
await db.Database.ExecuteSqlRawAsync(statement, cancellationToken); await db.Database.ExecuteSqlRawAsync(statement, cancellationToken);
} }
@@ -46,13 +67,13 @@ public sealed class DevelopmentSqliteMigrator(
await db.Database.ExecuteSqlInterpolatedAsync( await db.Database.ExecuteSqlInterpolatedAsync(
$""" $"""
INSERT INTO "__DevelopmentSchemaHistory" ("MigrationId", "AppliedAt") INSERT INTO "__DevelopmentSchemaHistory" ("MigrationId", "AppliedAt")
VALUES ({PeopleAndCoursesMigration}, {DateTime.UtcNow}); VALUES ({migrationId}, {DateTime.UtcNow});
""", """,
cancellationToken); cancellationToken);
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
logger.LogInformation( logger.LogInformation(
"Applied SQLite development schema migration {MigrationId}.", "Applied SQLite development schema migration {MigrationId}.",
PeopleAndCoursesMigration); migrationId);
} }
private static readonly string[] PeopleAndCoursesStatements = private static readonly string[] PeopleAndCoursesStatements =
@@ -166,4 +187,149 @@ public sealed class DevelopmentSqliteMigrator(
ON "Courses" ("CollegeId", "Nature"); ON "Courses" ("CollegeId", "Nature");
""" """
]; ];
private static readonly string[] CurriculumPlansStatements =
[
"""
CREATE TABLE IF NOT EXISTS "CurriculumPlans" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CurriculumPlans" PRIMARY KEY,
"MajorId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Version" TEXT NOT NULL,
"EffectiveGrade" INTEGER NOT NULL,
"TotalCredits" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Description" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CurriculumPlans_Majors_MajorId"
FOREIGN KEY ("MajorId") REFERENCES "Majors" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CurriculumPlans_MajorId_EffectiveGrade_Version"
ON "CurriculumPlans" ("MajorId", "EffectiveGrade", "Version");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CurriculumPlans_Status_EffectiveGrade"
ON "CurriculumPlans" ("Status", "EffectiveGrade");
""",
"""
CREATE TABLE IF NOT EXISTS "CurriculumModules" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CurriculumModules" PRIMARY KEY,
"CurriculumPlanId" TEXT NOT NULL,
"Code" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"RequiredCredits" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CurriculumModules_CurriculumPlans_CurriculumPlanId"
FOREIGN KEY ("CurriculumPlanId") REFERENCES "CurriculumPlans" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CurriculumModules_CurriculumPlanId_Code"
ON "CurriculumModules" ("CurriculumPlanId", "Code");
""",
"""
CREATE TABLE IF NOT EXISTS "CurriculumCourses" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CurriculumCourses" PRIMARY KEY,
"CurriculumModuleId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"RecommendedSemester" INTEGER NOT NULL,
"Type" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CurriculumCourses_CurriculumModules_CurriculumModuleId"
FOREIGN KEY ("CurriculumModuleId") REFERENCES "CurriculumModules" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CurriculumCourses_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CurriculumCourses_CurriculumModuleId_CourseId"
ON "CurriculumCourses" ("CurriculumModuleId", "CourseId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_CurriculumCourses_CourseId"
ON "CurriculumCourses" ("CourseId");
"""
];
private static readonly string[] TeachingTasksStatements =
[
"""
CREATE TABLE IF NOT EXISTS "TeachingTasks" (
"Id" TEXT NOT NULL CONSTRAINT "PK_TeachingTasks" PRIMARY KEY,
"TaskNumber" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"AcademicTermId" TEXT NOT NULL,
"CourseId" TEXT NOT NULL,
"Capacity" INTEGER NOT NULL,
"StartWeek" INTEGER NOT NULL,
"EndWeek" INTEGER NOT NULL,
"WeeklyHours" INTEGER NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_TeachingTasks_AcademicTerms_AcademicTermId"
FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT,
CONSTRAINT "FK_TeachingTasks_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TeachingTasks_TaskNumber"
ON "TeachingTasks" ("TaskNumber");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_TeachingTasks_AcademicTermId_Status"
ON "TeachingTasks" ("AcademicTermId", "Status");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_TeachingTasks_CourseId"
ON "TeachingTasks" ("CourseId");
""",
"""
CREATE TABLE IF NOT EXISTS "TeachingTaskTeachers" (
"TeachingTaskId" TEXT NOT NULL,
"TeacherId" TEXT NOT NULL,
"IsPrimary" INTEGER NOT NULL,
CONSTRAINT "PK_TeachingTaskTeachers"
PRIMARY KEY ("TeachingTaskId", "TeacherId"),
CONSTRAINT "FK_TeachingTaskTeachers_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskTeachers_Teachers_TeacherId"
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_TeachingTaskTeachers_TeacherId"
ON "TeachingTaskTeachers" ("TeacherId");
""",
"""
CREATE TABLE IF NOT EXISTS "TeachingTaskClasses" (
"TeachingTaskId" TEXT NOT NULL,
"AdministrativeClassId" TEXT NOT NULL,
CONSTRAINT "PK_TeachingTaskClasses"
PRIMARY KEY ("TeachingTaskId", "AdministrativeClassId"),
CONSTRAINT "FK_TeachingTaskClasses_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_TeachingTaskClasses_AdministrativeClasses_AdministrativeClassId"
FOREIGN KEY ("AdministrativeClassId") REFERENCES "AdministrativeClasses" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_TeachingTaskClasses_AdministrativeClassId"
ON "TeachingTaskClasses" ("AdministrativeClassId");
"""
];
} }
@@ -0,0 +1,140 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CurriculumPlans : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CurriculumPlans",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
MajorId = 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),
EffectiveGrade = table.Column<int>(type: "int", nullable: false),
TotalCredits = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, 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_CurriculumPlans", x => x.Id);
table.ForeignKey(
name: "FK_CurriculumPlans_Majors_MajorId",
column: x => x.MajorId,
principalTable: "Majors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CurriculumModules",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CurriculumPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
Code = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false),
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
RequiredCredits = table.Column<decimal>(type: "decimal(6,2)", precision: 6, scale: 2, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CurriculumModules", x => x.Id);
table.ForeignKey(
name: "FK_CurriculumModules_CurriculumPlans_CurriculumPlanId",
column: x => x.CurriculumPlanId,
principalTable: "CurriculumPlans",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "CurriculumCourses",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CurriculumModuleId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
RecommendedSemester = table.Column<int>(type: "int", nullable: false),
Type = 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_CurriculumCourses", x => x.Id);
table.ForeignKey(
name: "FK_CurriculumCourses_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CurriculumCourses_CurriculumModules_CurriculumModuleId",
column: x => x.CurriculumModuleId,
principalTable: "CurriculumModules",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CurriculumCourses_CourseId",
table: "CurriculumCourses",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_CurriculumCourses_CurriculumModuleId_CourseId",
table: "CurriculumCourses",
columns: new[] { "CurriculumModuleId", "CourseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CurriculumModules_CurriculumPlanId_Code",
table: "CurriculumModules",
columns: new[] { "CurriculumPlanId", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CurriculumPlans_MajorId_EffectiveGrade_Version",
table: "CurriculumPlans",
columns: new[] { "MajorId", "EffectiveGrade", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CurriculumPlans_Status_EffectiveGrade",
table: "CurriculumPlans",
columns: new[] { "Status", "EffectiveGrade" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CurriculumCourses");
migrationBuilder.DropTable(
name: "CurriculumModules");
migrationBuilder.DropTable(
name: "CurriculumPlans");
}
}
}
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class TeachingTasks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TeachingTasks",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TaskNumber = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
AcademicTermId = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
Capacity = table.Column<int>(type: "int", nullable: false),
StartWeek = table.Column<int>(type: "int", nullable: false),
EndWeek = table.Column<int>(type: "int", nullable: false),
WeeklyHours = table.Column<int>(type: "int", 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_TeachingTasks", x => x.Id);
table.ForeignKey(
name: "FK_TeachingTasks_AcademicTerms_AcademicTermId",
column: x => x.AcademicTermId,
principalTable: "AcademicTerms",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTasks_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskClasses",
columns: table => new
{
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
AdministrativeClassId = table.Column<Guid>(type: "char(36)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskClasses", x => new { x.TeachingTaskId, x.AdministrativeClassId });
table.ForeignKey(
name: "FK_TeachingTaskClasses_AdministrativeClasses_AdministrativeClas~",
column: x => x.AdministrativeClassId,
principalTable: "AdministrativeClasses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskClasses_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "TeachingTaskTeachers",
columns: table => new
{
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false),
IsPrimary = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TeachingTaskTeachers", x => new { x.TeachingTaskId, x.TeacherId });
table.ForeignKey(
name: "FK_TeachingTaskTeachers_Teachers_TeacherId",
column: x => x.TeacherId,
principalTable: "Teachers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TeachingTaskTeachers_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskClasses_AdministrativeClassId",
table: "TeachingTaskClasses",
column: "AdministrativeClassId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTasks_AcademicTermId_Status",
table: "TeachingTasks",
columns: new[] { "AcademicTermId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_TeachingTasks_CourseId",
table: "TeachingTasks",
column: "CourseId");
migrationBuilder.CreateIndex(
name: "IX_TeachingTasks_TaskNumber",
table: "TeachingTasks",
column: "TaskNumber",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TeachingTaskTeachers_TeacherId",
table: "TeachingTaskTeachers",
column: "TeacherId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TeachingTaskClasses");
migrationBuilder.DropTable(
name: "TeachingTaskTeachers");
migrationBuilder.DropTable(
name: "TeachingTasks");
}
}
}
@@ -72,7 +72,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AcademicTerms", (string)null); b.ToTable("AcademicTerms");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
@@ -121,7 +121,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("AdministrativeClasses", (string)null); b.ToTable("AdministrativeClasses");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
@@ -164,7 +164,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Buildings", (string)null); b.ToTable("Buildings");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b =>
@@ -205,7 +205,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Campuses", (string)null); b.ToTable("Campuses");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b =>
@@ -258,7 +258,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Classrooms", (string)null); b.ToTable("Classrooms");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
@@ -304,7 +304,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Colleges", (string)null); b.ToTable("Colleges");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
@@ -374,7 +374,137 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Courses", (string)null); b.ToTable("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CurriculumModuleId")
.HasColumnType("char(36)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<int>("RecommendedSemester")
.HasColumnType("int");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CourseId");
b.HasIndex("CurriculumModuleId", "CourseId")
.IsUnique();
b.ToTable("CurriculumCourses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("CurriculumPlanId")
.HasColumnType("char(36)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<decimal>("RequiredCredits")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("CurriculumPlanId", "Code")
.IsUnique();
b.ToTable("CurriculumModules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<int>("EffectiveGrade")
.HasColumnType("int");
b.Property<Guid>("MajorId")
.HasColumnType("char(36)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("varchar(120)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<decimal>("TotalCredits")
.HasPrecision(6, 2)
.HasColumnType("decimal(6,2)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.HasKey("Id");
b.HasIndex("Status", "EffectiveGrade");
b.HasIndex("MajorId", "EffectiveGrade", "Version")
.IsUnique();
b.ToTable("CurriculumPlans");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
@@ -424,7 +554,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("IsEnabled", "SortOrder"); b.HasIndex("IsEnabled", "SortOrder");
b.ToTable("Majors", (string)null); b.ToTable("Majors");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b =>
@@ -493,7 +623,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("AdministrativeClassId", "Status"); b.HasIndex("AdministrativeClassId", "Status");
b.ToTable("Students", (string)null); b.ToTable("Students");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b =>
@@ -561,7 +691,102 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("CollegeId", "Status"); b.HasIndex("CollegeId", "Status");
b.ToTable("Teachers", (string)null); b.ToTable("Teachers");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("AcademicTermId")
.HasColumnType("char(36)");
b.Property<int>("Capacity")
.HasColumnType("int");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("EndWeek")
.HasColumnType("int");
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>("StartWeek")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<string>("TaskNumber")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("varchar(40)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("WeeklyHours")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CourseId");
b.HasIndex("TaskNumber")
.IsUnique();
b.HasIndex("AcademicTermId", "Status");
b.ToTable("TeachingTasks");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<Guid>("AdministrativeClassId")
.HasColumnType("char(36)");
b.HasKey("TeachingTaskId", "AdministrativeClassId");
b.HasIndex("AdministrativeClassId");
b.ToTable("TeachingTaskClasses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b =>
{
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<Guid>("TeacherId")
.HasColumnType("char(36)");
b.Property<bool>("IsPrimary")
.HasColumnType("tinyint(1)");
b.HasKey("TeachingTaskId", "TeacherId");
b.HasIndex("TeacherId");
b.ToTable("TeachingTaskTeachers");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b =>
@@ -726,7 +951,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("CreatedAt"); b.HasIndex("CreatedAt");
b.ToTable("AuditLogs", (string)null); b.ToTable("AuditLogs");
}); });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
@@ -882,6 +1107,47 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College"); b.Navigation("College");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule")
.WithMany("Courses")
.HasForeignKey("CurriculumModuleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Course");
b.Navigation("CurriculumModule");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan")
.WithMany("Modules")
.HasForeignKey("CurriculumPlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CurriculumPlan");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major")
.WithMany()
.HasForeignKey("MajorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
@@ -896,7 +1162,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
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")
.WithMany() .WithMany("Students")
.HasForeignKey("AdministrativeClassId") .HasForeignKey("AdministrativeClassId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
@@ -925,6 +1191,63 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("College"); b.Navigation("College");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm")
.WithMany()
.HasForeignKey("AcademicTermId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany()
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("AcademicTerm");
b.Navigation("Course");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass")
.WithMany()
.HasForeignKey("AdministrativeClassId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany("Classes")
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AdministrativeClass");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher")
.WithMany()
.HasForeignKey("TeacherId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany("Teachers")
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Teacher");
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null)
@@ -975,6 +1298,28 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
{
b.Navigation("Students");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b =>
{
b.Navigation("Courses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b =>
{
b.Navigation("Modules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b =>
{
b.Navigation("Classes");
b.Navigation("Teachers");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+94 -24
View File
@@ -89,18 +89,41 @@ public sealed class PersistenceTests : IAsyncLifetime
MajorId = major.Id, MajorId = major.Id,
Grade = 2026 Grade = 2026
}; };
var course = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
PracticeHours = 24,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var teacher = new Teacher
{
TeacherNumber = "T2026001",
Name = "陈老师",
CollegeId = college.Id,
Status = TeacherStatus.Active
};
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 7),
EndDate = new DateOnly(2027, 1, 17),
IsCurrent = true
};
_db.AddRange( _db.AddRange(
campus, campus,
college, college,
major, major,
administrativeClass, administrativeClass,
new Teacher teacher,
{
TeacherNumber = "T2026001",
Name = "陈老师",
CollegeId = college.Id,
Status = TeacherStatus.Active
},
new Student new Student
{ {
StudentNumber = "202601001", StudentNumber = "202601001",
@@ -110,30 +133,77 @@ public sealed class PersistenceTests : IAsyncLifetime
EnrollmentDate = new DateOnly(2026, 9, 7), EnrollmentDate = new DateOnly(2026, 9, 7),
Status = StudentStatus.Active Status = StudentStatus.Active
}, },
new Course course,
{ term);
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 40,
PracticeHours = 24,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
});
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
var teacher = await _db.Teachers.Include(x => x.College).SingleAsync(); _db.CurriculumPlans.Add(new CurriculumPlan
{
MajorId = major.Id,
Name = "计科培养方案",
Version = "2026版",
EffectiveGrade = 2026,
TotalCredits = 4,
Status = CurriculumPlanStatus.Draft,
Modules =
[
new CurriculumModule
{
Code = "BASIC",
Name = "专业基础",
RequiredCredits = 4,
Courses =
[
new CurriculumCourse
{
CourseId = course.Id,
RecommendedSemester = 1,
Type = CurriculumCourseType.Required
}
]
}
]
});
_db.TeachingTasks.Add(new TeachingTask
{
TaskNumber = "2026-1-CS101-01",
Name = "程序设计基础教学班",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 60,
Teachers =
[
new TeachingTaskTeacher { TeacherId = teacher.Id, IsPrimary = true }
],
Classes =
[
new TeachingTaskClass { AdministrativeClassId = administrativeClass.Id }
]
});
await _db.SaveChangesAsync();
var savedTeacher = await _db.Teachers.Include(x => x.College).SingleAsync();
var student = await _db.Students var student = await _db.Students
.Include(x => x.AdministrativeClass) .Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major) .ThenInclude(x => x!.Major)
.SingleAsync(); .SingleAsync();
var course = await _db.Courses.Include(x => x.College).SingleAsync(); var savedCourse = await _db.Courses.Include(x => x.College).SingleAsync();
var curriculumPlan = await _db.CurriculumPlans
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.SingleAsync();
var teachingTask = await _db.TeachingTasks
.Include(x => x.Teachers)
.Include(x => x.Classes)
.SingleAsync();
Assert.Equal("计算机学院", teacher.College!.Name); Assert.Equal("计算机学院", savedTeacher.College!.Name);
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name); Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name);
Assert.Equal(4m, course.Credits); Assert.Equal(4m, savedCourse.Credits);
Assert.Equal(course.TotalHours, course.LectureHours + course.PracticeHours); 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);
} }
} }
+20 -2
View File
@@ -6,6 +6,8 @@ import {
DataAnalysis, DataAnalysis,
OfficeBuilding, OfficeBuilding,
Operation, Operation,
Reading,
Tickets,
User, User,
UserFilled, UserFilled,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
@@ -23,6 +25,8 @@ const pageTitle = computed(() => {
'base-data': '基础数据', 'base-data': '基础数据',
personnel: '人员档案', personnel: '人员档案',
courses: '课程库', courses: '课程库',
curriculum: '培养方案',
'teaching-tasks': '教学任务',
users: '用户与权限', users: '用户与权限',
} }
return titles[String(route.name)] ?? '教务管理' return titles[String(route.name)] ?? '教务管理'
@@ -80,6 +84,20 @@ onMounted(() => auth.refresh().catch(() => undefined))
<el-icon><Collection /></el-icon> <el-icon><Collection /></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', 'CollegeAdmin'].includes(role))"
index="/curriculum"
>
<el-icon><Reading /></el-icon>
<template #title>培养方案</template>
</el-menu-item>
<el-menu-item
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role))"
index="/teaching-tasks"
>
<el-icon><Tickets /></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>
@@ -87,8 +105,8 @@ onMounted(() => auth.refresh().catch(() => undefined))
</el-menu> </el-menu>
<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>
+12
View File
@@ -39,6 +39,18 @@ const router = createRouter({
name: 'courses', name: 'courses',
component: () => import('../views/CoursesView.vue'), component: () => import('../views/CoursesView.vue'),
}, },
{
path: 'curriculum',
name: 'curriculum',
component: () => import('../views/CurriculumView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{
path: 'teaching-tasks',
name: 'teaching-tasks',
component: () => import('../views/TeachingTasksView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'] },
},
{ {
path: 'users', path: 'users',
name: 'users', name: 'users',
+66
View File
@@ -102,6 +102,7 @@ button { cursor: pointer; }
.phase-line span.active::before { background: var(--amber); box-shadow: 0 0 0 4px #f5ead6; } .phase-line span.active::before { background: var(--amber); box-shadow: 0 0 0 4px #f5ead6; }
.page-stack { display: grid; gap: 18px; } .page-stack { display: grid; gap: 18px; }
.page-stack > * { min-width: 0; }
.page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } .page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; } .page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; }
.page-intro p { margin: 0; color: var(--muted); font-size: 13px; } .page-intro p { margin: 0; color: var(--muted); font-size: 13px; }
@@ -156,6 +157,55 @@ button { cursor: pointer; }
.ledger-rule { display: flex; align-items: center; gap: 0; position: relative; z-index: 1; } .ledger-rule { display: flex; align-items: center; gap: 0; position: relative; z-index: 1; }
.ledger-rule span { padding: 8px 28px; border-right: 1px solid rgba(255,255,255,.12); color: #d2d8e9; font-size: 11px; letter-spacing: .08em; } .ledger-rule span { padding: 8px 28px; border-right: 1px solid rgba(255,255,255,.12); color: #d2d8e9; font-size: 11px; letter-spacing: .08em; }
.curriculum-filter { padding: 14px 18px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; border: 1px solid var(--line); background: white; }
.curriculum-filter > .el-input { width: 260px; }
.curriculum-filter > .el-select { width: 170px; }
.curriculum-workspace { min-height: 620px; display: grid; grid-template-columns: 285px minmax(0, 1fr); border: 1px solid var(--line); background: white; }
.plan-list { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: #fafbfc; }
.plan-list > button { position: relative; padding: 19px 20px; display: grid; gap: 6px; text-align: left; border: none; border-bottom: 1px solid var(--line); background: transparent; }
.plan-list > button:hover { background: white; }
.plan-list > button.active { padding-left: 17px; border-left: 3px solid var(--teal); background: white; }
.plan-list > button span { padding-right: 55px; color: var(--muted); font-size: 10px; }
.plan-list > button b { font-size: 13px; line-height: 1.5; }
.plan-list > button small { color: #969eac; font-size: 10px; }
.plan-list > button i { position: absolute; right: 14px; top: 16px; padding: 3px 7px; border-radius: 10px; color: #777f8d; background: #eef0f3; font-size: 9px; font-style: normal; }
.plan-list > button i.published { color: var(--teal); background: #e5f4f1; }
.plan-list > button i.archived { color: #8d7760; background: #f2ece4; }
.plan-pagination { margin-top: auto; min-height: 58px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); }
.plan-pagination > span { color: var(--muted); font-size: 10px; }
.plan-detail { min-width: 0; padding: 26px; }
.plan-detail-head { display: flex; justify-content: space-between; gap: 20px; padding-bottom: 21px; border-bottom: 1px solid var(--line); }
.plan-detail-head > div:first-child > span { color: var(--teal); font-size: 10px; letter-spacing: .08em; }
.plan-detail-head h3 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 23px; }
.plan-detail-head p { margin: 0; color: var(--muted); font-size: 11px; }
.plan-actions { display: flex; flex-wrap: wrap; align-content: flex-start; justify-content: flex-end; gap: 7px; }
.plan-actions .el-button + .el-button { margin-left: 0; }
.plan-metrics { margin: 18px 0; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); }
.plan-metrics > div { padding: 15px 17px; border-right: 1px solid var(--line); }
.plan-metrics > div:last-child { border-right: none; }
.plan-metrics span, .plan-metrics b { display: block; }
.plan-metrics span { color: var(--muted); font-size: 10px; }
.plan-metrics b { margin-top: 6px; font: 700 18px/1.2 Consolas, monospace; }
.plan-metrics b.warning { color: #b36f12; }
.module-toolbar { margin: 25px 0 12px; display: flex; align-items: center; justify-content: space-between; }
.module-toolbar b, .module-toolbar span { display: block; }
.module-toolbar b { font-size: 15px; }
.module-toolbar span { margin-top: 4px; color: var(--muted); font-size: 10px; }
.curriculum-module { margin-top: 12px; border: 1px solid var(--line); }
.curriculum-module > header { min-height: 72px; padding: 13px 16px; display: flex; align-items: center; gap: 18px; background: #f8fafb; border-bottom: 1px solid var(--line); }
.curriculum-module > header > div:first-child { min-width: 160px; }
.curriculum-module > header span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .12em; }
.curriculum-module h4 { margin: 6px 0 0; font-size: 14px; }
.curriculum-module > header p { margin: 0 auto 0 0; color: var(--muted); font-size: 11px; }
.curriculum-module > header > div:last-child { display: flex; align-items: center; white-space: nowrap; }
.curriculum-course-table { min-height: 80px; }
.task-summary { min-height: 82px; padding: 15px 22px; display: flex; align-items: center; gap: 28px; color: white; background: linear-gradient(108deg, #17295a, #263f80); }
.task-summary > div { min-width: 170px; display: flex; align-items: baseline; gap: 8px; }
.task-summary span, .task-summary small { color: #b8c1de; font-size: 10px; }
.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; }
.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; }
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; } .login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
@@ -222,6 +272,22 @@ button { cursor: pointer; }
.ledger-rule { overflow-x: auto; } .ledger-rule { overflow-x: auto; }
.ledger-rule span { padding: 12px 20px; white-space: nowrap; } .ledger-rule span { padding: 12px 20px; white-space: nowrap; }
.data-card { overflow: hidden; } .data-card { overflow: hidden; }
.curriculum-filter > .el-input { width: 100%; }
.curriculum-filter > .el-select { flex: 1 1 140px; width: auto; }
.curriculum-workspace { display: block; }
.plan-list { max-height: 310px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
.plan-pagination { position: sticky; bottom: 0; background: #fafbfc; }
.plan-detail { padding: 18px 14px; }
.plan-detail-head { display: block; }
.plan-actions { margin-top: 16px; justify-content: flex-start; }
.plan-metrics { grid-template-columns: 1fr 1fr; }
.plan-metrics > div:nth-child(2) { border-right: none; }
.plan-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.curriculum-module > header { align-items: flex-start; flex-wrap: wrap; }
.curriculum-module > header p { order: 3; width: 100%; }
.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; }
.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; }
+453
View File
@@ -0,0 +1,453 @@
<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'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const detailLoading = ref(false)
const plans = ref<any[]>([])
const total = ref(0)
const selected = ref<any | null>(null)
const majors = ref<any[]>([])
const courses = ref<any[]>([])
const planDialog = ref(false)
const moduleDialog = ref(false)
const courseDialog = ref(false)
const cloneDialog = ref(false)
const editingPlanId = ref('')
const editingModuleId = ref('')
const editingCourseId = ref('')
const activeModuleId = ref('')
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
majorId: undefined as string | undefined,
effectiveGrade: undefined as number | undefined,
status: undefined as string | undefined,
})
const planForm = reactive<Record<string, any>>({})
const moduleForm = reactive<Record<string, any>>({})
const courseForm = reactive<Record<string, any>>({})
const cloneForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Archived: '已归档',
}
const courseTypeLabels: Record<string, string> = {
Required: '必修',
Elective: '选修',
}
const availableMajors = computed(() =>
auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId
? majors.value.filter((item) => item.collegeId === auth.user!.collegeId)
: majors.value,
)
const isDraft = computed(() => selected.value?.status === 'Draft')
const configuredCredits = computed(() =>
selected.value?.modules.reduce(
(sum: number, module: any) => sum + Number(module.requiredCredits),
0,
) ?? 0,
)
async function loadPlans(keepSelection = true) {
loading.value = true
try {
const { data } = await http.get('/curriculum-plans', {
params: {
...query,
keyword: query.keyword || undefined,
},
})
plans.value = data.items
total.value = data.total
const targetId = keepSelection && selected.value
? selected.value.id
: plans.value[0]?.id
if (targetId && plans.value.some((item) => item.id === targetId)) {
await loadDetail(targetId)
} 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(`/curriculum-plans/${id}`)).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
majorId: undefined,
effectiveGrade: undefined,
status: undefined,
})
loadPlans(false)
}
function openPlan(row?: any) {
editingPlanId.value = row?.id ?? ''
Object.keys(planForm).forEach((key) => delete planForm[key])
Object.assign(planForm, {
majorId: row?.majorId,
name: row?.name ?? '',
version: row?.version ?? `${new Date().getFullYear()}`,
effectiveGrade: row?.effectiveGrade ?? new Date().getFullYear(),
totalCredits: row?.totalCredits ?? 160,
description: row?.description ?? '',
})
planDialog.value = true
}
async function savePlan() {
if (!planForm.majorId || !planForm.name?.trim() || !planForm.version?.trim()) {
ElMessage.warning('请填写专业、方案名称和版本号。')
return
}
try {
if (editingPlanId.value) {
await http.put(`/curriculum-plans/${editingPlanId.value}`, planForm)
} else {
await http.post('/curriculum-plans', planForm)
}
ElMessage.success(editingPlanId.value ? '方案信息已更新' : '培养方案草稿已创建')
planDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openClone() {
Object.assign(cloneForm, {
name: selected.value.name,
version: `${new Date().getFullYear()}`,
effectiveGrade: Math.max(new Date().getFullYear(), selected.value.effectiveGrade + 1),
})
cloneDialog.value = true
}
async function clonePlan() {
try {
await http.post(`/curriculum-plans/${selected.value.id}/clone`, cloneForm)
ElMessage.success('已复制为新的草稿版本')
cloneDialog.value = false
await loadPlans(false)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function publishPlan() {
try {
await ElMessageBox.confirm(
'发布后该版本将锁定,并自动归档同专业、同年级的旧发布版本。确定发布吗?',
'发布培养方案',
{ type: 'warning', confirmButtonText: '确认发布', cancelButtonText: '取消' },
)
await http.post(`/curriculum-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',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
await http.delete(`/curriculum-plans/${selected.value.id}`)
ElMessage.success('草稿已删除')
await loadPlans(false)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openModule(module?: any) {
editingModuleId.value = module?.id ?? ''
Object.assign(moduleForm, {
code: module?.code ?? '',
name: module?.name ?? '',
requiredCredits: module?.requiredCredits ?? 0,
sortOrder: module?.sortOrder ?? (selected.value.modules.length + 1) * 10,
})
moduleDialog.value = true
}
async function saveModule() {
if (!moduleForm.code?.trim() || !moduleForm.name?.trim()) {
ElMessage.warning('请填写模块编码和名称。')
return
}
try {
const base = `/curriculum-plans/${selected.value.id}/modules`
if (editingModuleId.value) await http.put(`${base}/${editingModuleId.value}`, moduleForm)
else await http.post(base, moduleForm)
moduleDialog.value = false
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteModule(module: any) {
try {
await ElMessageBox.confirm(`删除“${module.name}”及其中课程?`, '删除课程模块', {
type: 'warning',
})
await http.delete(`/curriculum-plans/${selected.value.id}/modules/${module.id}`)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function openCourse(moduleId: string, item?: any) {
activeModuleId.value = moduleId
editingCourseId.value = item?.id ?? ''
Object.assign(courseForm, {
courseId: item?.courseId,
recommendedSemester: item?.recommendedSemester ?? 1,
type: item?.type ?? 'Required',
notes: item?.notes ?? '',
})
courseDialog.value = true
}
async function saveCourse() {
if (!courseForm.courseId) {
ElMessage.warning('请选择课程。')
return
}
try {
const base = `/curriculum-plans/${selected.value.id}/modules/${activeModuleId.value}/courses`
if (editingCourseId.value) await http.put(`${base}/${editingCourseId.value}`, courseForm)
else await http.post(base, courseForm)
courseDialog.value = false
await loadDetail(selected.value.id)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function deleteCourse(moduleId: string, item: any) {
try {
await ElMessageBox.confirm(`从方案中移除“${item.courseName}”?`, '移除课程', {
type: 'warning',
})
await http.delete(
`/curriculum-plans/${selected.value.id}/modules/${moduleId}/courses/${item.id}`,
)
await loadDetail(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [majorRes, courseRes] = await Promise.all([
http.get('/base-data/majors'),
http.get('/courses', { params: { page: 1, pageSize: 100, isEnabled: true } }),
])
majors.value = majorRes.data
courses.value = courseRes.data.items
await loadPlans(false)
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">PROGRAMME OF STUDY</span>
<h2>培养方案</h2>
<p>按专业和入学年级管理版本化课程结构发布后锁定避免影响历史年级</p>
</div>
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
</section>
<section class="curriculum-filter">
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索方案、版本或专业" @keyup.enter="query.page = 1; loadPlans(false)" />
<el-select v-model="query.majorId" clearable filterable placeholder="全部专业">
<el-option v-for="item in availableMajors" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-input-number v-model="query.effectiveGrade" :min="2000" :max="2200" placeholder="适用年级" />
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="query.page = 1; loadPlans(false)">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</section>
<div class="curriculum-workspace">
<aside v-loading="loading" class="plan-list">
<button
v-for="plan in plans"
:key="plan.id"
type="button"
:class="{ active: selected?.id === plan.id }"
@click="loadDetail(plan.id)"
>
<span>{{ plan.majorName }} · {{ plan.effectiveGrade }}</span>
<b>{{ plan.name }}</b>
<small>{{ plan.version }} · {{ plan.courseCount }} 门课程</small>
<i :class="plan.status.toLowerCase()">{{ statusLabels[plan.status] }}</i>
</button>
<el-empty v-if="!loading && plans.length === 0" description="暂无培养方案" :image-size="70" />
<div class="plan-pagination">
<span> {{ total }} </span>
<el-pagination v-model:current-page="query.page" :page-size="query.pageSize" :total="total" layout="prev, next" @current-change="loadPlans(false)" />
</div>
</aside>
<main v-loading="detailLoading" class="plan-detail">
<el-empty v-if="!selected" description="请选择或新建一份培养方案" />
<template v-else>
<header class="plan-detail-head">
<div>
<span>{{ selected.collegeName }} / {{ selected.majorName }}</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.version }} · 适用 {{ selected.effectiveGrade }} · {{ selected.schoolingYears }} 年制</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="success" :icon="Promotion" @click="publishPlan">发布</el-button>
<el-button v-if="isDraft" type="danger" plain @click="deletePlan">删除</el-button>
</div>
</header>
<div class="plan-metrics">
<div><span>方案状态</span><b>{{ statusLabels[selected.status] }}</b></div>
<div><span>总学分要求</span><b>{{ selected.totalCredits }}</b></div>
<div><span>模块学分合计</span><b :class="{ warning: configuredCredits !== Number(selected.totalCredits) }">{{ configuredCredits }}</b></div>
<div><span>课程数量</span><b>{{ selected.modules.reduce((sum: number, item: any) => sum + item.courses.length, 0) }}</b></div>
</div>
<div class="module-toolbar">
<div>
<b>课程结构</b>
<span>发布前模块学分合计须与方案总学分一致</span>
</div>
<el-button v-if="isDraft" :icon="Plus" @click="openModule()">新增模块</el-button>
</div>
<section v-for="module in selected.modules" :key="module.id" class="curriculum-module">
<header>
<div>
<span>{{ module.code }}</span>
<h4>{{ module.name }}</h4>
</div>
<p>最低 {{ module.requiredCredits }} 学分 · 已配置 {{ module.assignedCredits }} 学分</p>
<div v-if="isDraft">
<el-button link type="primary" @click="openModule(module)">编辑</el-button>
<el-button link type="danger" @click="deleteModule(module)">删除</el-button>
<el-button size="small" :icon="Plus" @click="openCourse(module.id)">添加课程</el-button>
</div>
</header>
<el-table :data="module.courses" class="curriculum-course-table">
<el-table-column prop="courseCode" label="课程编码" width="115" />
<el-table-column label="课程名称" min-width="180">
<template #default="{ row }"><b>{{ row.courseName }}</b></template>
</el-table-column>
<el-table-column label="类别" width="80">
<template #default="{ row }">{{ courseTypeLabels[row.type] }}</template>
</el-table-column>
<el-table-column prop="credits" label="学分" width="70" />
<el-table-column prop="totalHours" label="学时" width="70" />
<el-table-column label="建议学期" width="90">
<template #default="{ row }"> {{ row.recommendedSemester }} 学期</template>
</el-table-column>
<el-table-column v-if="isDraft" label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" @click="openCourse(module.id, row)">编辑</el-button>
<el-button link type="danger" @click="deleteCourse(module.id, row)">移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="该模块尚未配置课程" :image-size="54" /></template>
</el-table>
</section>
<el-empty v-if="selected.modules.length === 0" description="先建立课程模块,再添加课程" />
</template>
</main>
</div>
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑培养方案' : '新建培养方案'" width="620px">
<el-form label-position="top">
<el-form-item label="适用专业" required>
<el-select v-model="planForm.majorId" filterable><el-option v-for="item in availableMajors" :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" /></el-form-item>
<div class="form-grid">
<el-form-item label="版本号" required><el-input v-model="planForm.version" /></el-form-item>
<el-form-item label="适用入学年级" required><el-input-number v-model="planForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item>
</div>
<el-form-item label="毕业总学分要求" required><el-input-number v-model="planForm.totalCredits" :min="0.1" :precision="1" :step="0.5" /></el-form-item>
<el-form-item label="方案说明"><el-input v-model="planForm.description" type="textarea" :rows="3" /></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>
<div class="form-grid">
<el-form-item label="新版本号" required><el-input v-model="cloneForm.version" /></el-form-item>
<el-form-item label="适用入学年级" required><el-input-number v-model="cloneForm.effectiveGrade" :min="2000" :max="2200" /></el-form-item>
</div>
</el-form>
<template #footer><el-button @click="cloneDialog = false">取消</el-button><el-button type="primary" @click="clonePlan">复制</el-button></template>
</el-dialog>
<el-dialog v-model="moduleDialog" :title="editingModuleId ? '编辑课程模块' : '新增课程模块'" width="520px">
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="模块编码" required><el-input v-model="moduleForm.code" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="moduleForm.sortOrder" :min="0" /></el-form-item>
</div>
<el-form-item label="模块名称" required><el-input v-model="moduleForm.name" /></el-form-item>
<el-form-item label="最低学分要求"><el-input-number v-model="moduleForm.requiredCredits" :min="0" :precision="1" :step="0.5" /></el-form-item>
</el-form>
<template #footer><el-button @click="moduleDialog = false">取消</el-button><el-button type="primary" @click="saveModule">保存</el-button></template>
</el-dialog>
<el-dialog v-model="courseDialog" :title="editingCourseId ? '编辑方案课程' : '添加方案课程'" width="560px">
<el-form label-position="top">
<el-form-item label="课程" required>
<el-select v-model="courseForm.courseId" filterable>
<el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="修读类别"><el-select v-model="courseForm.type"><el-option v-for="(label, value) in courseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="建议学期"><el-input-number v-model="courseForm.recommendedSemester" :min="1" :max="selected?.schoolingYears * 2" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="courseForm.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
</el-dialog>
</div>
</template>
+13 -3
View File
@@ -86,18 +86,28 @@ onMounted(async () => {
<b>教师档案 + 学生档案 + 课程库</b> <b>教师档案 + 学生档案 + 课程库</b>
<i class="done">已建立</i> <i class="done">已建立</i>
</div> </div>
<div>
<span>培养方案</span>
<b>{{ data.counts.curriculumPlans ?? 0 }} 个版本 · 模块化课程结构</b>
<i class="done">已建立</i>
</div>
<div>
<span>教学任务</span>
<b>{{ data.counts.teachingTasks ?? 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>培养方案</span> <span class="active">培养方案</span>
<span>教学运行</span> <span class="active">教学任务</span>
</div> </div>
</article> </article>
</section> </section>
+284
View File
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const loading = ref(false)
const rows = ref<any[]>([])
const total = ref(0)
const dialogVisible = ref(false)
const editingId = ref('')
const terms = ref<any[]>([])
const courses = ref<any[]>([])
const teachers = ref<any[]>([])
const classes = ref<any[]>([])
const majors = ref<any[]>([])
const query = reactive({
page: 1,
pageSize: 20,
keyword: '',
academicTermId: undefined as string | undefined,
status: undefined as string | undefined,
})
const form = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿',
Published: '已发布',
Closed: '已结课',
}
const availableClasses = computed(() => {
if (!auth.user?.roles.includes('CollegeAdmin') || !auth.user.collegeId) return classes.value
const ids = new Set(
majors.value.filter((item) => item.collegeId === auth.user!.collegeId).map((item) => item.id),
)
return classes.value.filter((item) => ids.has(item.majorId))
})
const selectedTeachers = computed(() =>
teachers.value.filter((item) => form.teacherIds?.includes(item.id)),
)
async function load() {
loading.value = true
try {
const { data } = await http.get('/teaching-tasks', {
params: {
...query,
keyword: query.keyword || undefined,
},
})
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
academicTermId: undefined,
status: undefined,
})
load()
}
function resetForm(detail?: any) {
Object.keys(form).forEach((key) => delete form[key])
const currentTerm = terms.value.find((item) => item.isCurrent)
Object.assign(form, {
taskNumber: '',
name: '',
academicTermId: currentTerm?.id,
courseId: undefined,
capacity: 60,
startWeek: 1,
endWeek: 16,
weeklyHours: 2,
teacherIds: [],
primaryTeacherId: undefined,
classIds: [],
notes: '',
}, detail ?? {})
}
function openCreate() {
editingId.value = ''
resetForm()
dialogVisible.value = true
}
async function openEdit(row: any) {
try {
const detail = (await http.get(`/teaching-tasks/${row.id}`)).data
editingId.value = row.id
resetForm(detail)
dialogVisible.value = true
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function onTeachersChanged(values: string[]) {
if (form.primaryTeacherId && !values.includes(form.primaryTeacherId)) {
form.primaryTeacherId = undefined
}
}
async function save() {
if (!form.taskNumber?.trim() || !form.name?.trim() || !form.academicTermId || !form.courseId) {
ElMessage.warning('请填写任务编号、名称、学期和课程。')
return
}
if (form.startWeek > form.endWeek) {
ElMessage.warning('开始周不能晚于结束周。')
return
}
try {
if (editingId.value) await http.put(`/teaching-tasks/${editingId.value}`, form)
else await http.post('/teaching-tasks', form)
ElMessage.success(editingId.value ? '教学任务已更新' : '教学任务草稿已创建')
dialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function remove(row: any) {
try {
await ElMessageBox.confirm(`确定删除教学任务“${row.name}”吗?`, '删除草稿', {
type: 'warning',
})
await http.delete(`/teaching-tasks/${row.id}`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function publish(row: any) {
try {
await ElMessageBox.confirm(
'发布后任务将锁定,并可进入排课与选课流程。确定发布吗?',
'发布教学任务',
{ type: 'warning', confirmButtonText: '发布', cancelButtonText: '取消' },
)
await http.post(`/teaching-tasks/${row.id}/publish`)
ElMessage.success('教学任务已发布')
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function closeTask(row: any) {
try {
await ElMessageBox.confirm('结课后将停止后续选课和排课调整,确定继续吗?', '教学任务结课', {
type: 'warning',
confirmButtonText: '确认结课',
cancelButtonText: '取消',
})
await http.post(`/teaching-tasks/${row.id}/close`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
const [termRes, courseRes, teacherRes, classRes, majorRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/courses', { params: { page: 1, pageSize: 100, isEnabled: true } }),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 100, teacherStatus: 'Active' } }),
http.get('/base-data/classes'),
http.get('/base-data/majors'),
])
terms.value = termRes.data
courses.value = courseRes.data.items
teachers.value = teacherRes.data.items
classes.value = classRes.data
majors.value = majorRes.data
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id
await load()
})
</script>
<template>
<div class="page-stack">
<section class="page-intro">
<div>
<span class="section-kicker">TEACHING COMMISSION</span>
<h2>教学任务</h2>
<p>将学期课程授课教师和行政班组合为可排课可选课的教学班</p>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建教学任务</el-button>
</section>
<section class="task-summary">
<div><span>当前结果</span><b>{{ total }}</b><small>个教学班</small></div>
<p>发布校验主讲教师唯一 · 行政班已关联 · 容量覆盖在籍学生 · 周次有效</p>
</section>
<section class="data-card">
<div class="filter-bar">
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" />
<el-select v-model="query.academicTermId" clearable placeholder="全部学期">
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="query.status" clearable placeholder="全部状态">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table task-table">
<el-table-column label="任务编号" width="155"><template #default="{ row }"><span class="registry-number">{{ row.taskNumber }}</span></template></el-table-column>
<el-table-column label="教学班 / 课程" min-width="230">
<template #default="{ row }"><div class="course-name"><b>{{ row.name }}</b><span>{{ row.courseCode }} · {{ row.courseName }}</span></div></template>
</el-table-column>
<el-table-column prop="termName" label="开课学期" min-width="180" />
<el-table-column label="授课教师" min-width="130"><template #default="{ row }">{{ row.teacherNames.join('、') || '未安排' }}</template></el-table-column>
<el-table-column label="行政班" min-width="150"><template #default="{ row }">{{ row.classNames.join('、') || '未关联' }}</template></el-table-column>
<el-table-column label="人数 / 容量" width="105"><template #default="{ row }">{{ row.studentCount }} / {{ row.capacity }}</template></el-table-column>
<el-table-column label="周次" width="105"><template #default="{ row }">{{ row.startWeek }}{{ row.endWeek }} </template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><span class="table-status" :class="{ off: row.status === 'Closed' }">{{ statusLabels[row.status] }}</span></template></el-table-column>
<el-table-column label="操作" width="185" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'Draft'" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'Draft'" link type="success" @click="publish(row)">发布</el-button>
<el-button v-if="row.status === 'Draft'" link type="danger" @click="remove(row)">删除</el-button>
<el-button v-if="row.status === 'Published'" link type="warning" @click="closeTask(row)">结课</el-button>
<span v-if="row.status === 'Closed'" class="muted-action">已归档</span>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的教学任务" /></template>
</el-table>
<div class="pagination-bar">
<span> {{ total }} 个教学班</span>
<el-pagination v-model:current-page="query.page" :page-size="query.pageSize" :total="total" layout="prev, pager, next" @current-change="load" />
</div>
</section>
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑教学任务' : '新建教学任务'" width="720px">
<el-form label-position="top">
<div class="form-grid">
<el-form-item label="任务编号" required><el-input v-model="form.taskNumber" placeholder="如 2026-1-CS101-01" /></el-form-item>
<el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item>
</div>
<div class="form-grid">
<el-form-item label="开课学期" required><el-select v-model="form.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-select v-model="form.courseId" filterable><el-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" /></el-select></el-form-item>
</div>
<div class="form-grid three">
<el-form-item label="容量"><el-input-number v-model="form.capacity" :min="1" /></el-form-item>
<el-form-item label="开始周"><el-input-number v-model="form.startWeek" :min="1" :max="30" /></el-form-item>
<el-form-item label="结束周"><el-input-number v-model="form.endWeek" :min="1" :max="30" /></el-form-item>
</div>
<el-form-item label="周学时"><el-input-number v-model="form.weeklyHours" :min="1" :max="40" /></el-form-item>
<el-form-item label="授课教师">
<el-select v-model="form.teacherIds" multiple filterable @change="onTeachersChanged">
<el-option v-for="item in teachers" :key="item.id" :label="`${item.teacherNumber} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="主讲教师">
<el-select v-model="form.primaryTeacherId" clearable :disabled="!form.teacherIds?.length">
<el-option v-for="item in selectedTeachers" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="关联行政班">
<el-select v-model="form.classIds" multiple filterable>
<el-option v-for="item in availableClasses" :key="item.id" :label="`${item.code} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="save">保存草稿</el-button></template>
</el-dialog>
</div>
</template>