培养方案:专业/年级版本、课程模块、学分校验、复制版本、发布锁定、旧版本归档。
教学任务:学期开课、授课教师、唯一主讲、合班、容量与周次校验、发布及结课。 SQLite 开发库已自动升级,无需删除原数据库。 新增对应 MySQL EF Core 迁移。 Vue 已编译进 wwwroot,仍可只运行 ASP.NET Core 单服务。 桌面端及 390px 窄屏页面验证通过,无前端控制台错误。
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查。
|
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课。
|
||||||
|
|
||||||
## 本地开发:热更新模式
|
## 本地开发:热更新模式
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+1165
File diff suppressed because it is too large
Load Diff
+140
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1329
File diff suppressed because it is too large
Load Diff
+142
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+357
-12
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user