课程库 Excel 导入导出已完成:
支持下载标准导入模板。 导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。 以课程编码为唯一键:存在则更新,不存在则新增。 校验学院编码、课程分类、课程性质、学分学时及考核方式。 整批原子导入,任一行错误则全部不写入,并提示具体行号。 学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。 界面入口沿用现有课程库工具栏样式。
This commit is contained in:
@@ -20,6 +20,9 @@ else {
|
|||||||
}
|
}
|
||||||
$stdoutPath = Join-Path $env:TEMP 'jiaowu-api-smoke.out.log'
|
$stdoutPath = Join-Path $env:TEMP 'jiaowu-api-smoke.out.log'
|
||||||
$stderrPath = Join-Path $env:TEMP 'jiaowu-api-smoke.err.log'
|
$stderrPath = Join-Path $env:TEMP 'jiaowu-api-smoke.err.log'
|
||||||
|
$courseExcelToken = [guid]::NewGuid().ToString('N')
|
||||||
|
$courseTemplatePath = Join-Path $env:TEMP "jiaowu-course-template-$courseExcelToken.xlsx"
|
||||||
|
$courseExportPath = Join-Path $env:TEMP "jiaowu-course-export-$courseExcelToken.xlsx"
|
||||||
$env:ASPNETCORE_ENVIRONMENT = 'Development'
|
$env:ASPNETCORE_ENVIRONMENT = 'Development'
|
||||||
$env:ASPNETCORE_URLS = 'http://localhost:5255'
|
$env:ASPNETCORE_URLS = 'http://localhost:5255'
|
||||||
$startParameters = @{
|
$startParameters = @{
|
||||||
@@ -78,6 +81,37 @@ 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
|
||||||
|
$courseCategories = Invoke-RestMethod `
|
||||||
|
-Uri 'http://localhost:5255/api/base-data/course-categories' `
|
||||||
|
-Headers $headers
|
||||||
|
if (@($courseCategories).Count -lt 10 -or
|
||||||
|
@($courses.items | Where-Object { $null -eq $_.courseCategoryId }).Count -gt 0) {
|
||||||
|
throw 'Course categories were not seeded or assigned to all courses.'
|
||||||
|
}
|
||||||
|
Invoke-WebRequest `
|
||||||
|
-Uri 'http://localhost:5255/api/courses/template' `
|
||||||
|
-Headers $headers `
|
||||||
|
-OutFile $courseTemplatePath |
|
||||||
|
Out-Null
|
||||||
|
Invoke-WebRequest `
|
||||||
|
-Uri 'http://localhost:5255/api/courses/export' `
|
||||||
|
-Headers $headers `
|
||||||
|
-OutFile $courseExportPath |
|
||||||
|
Out-Null
|
||||||
|
if ((Get-Item -LiteralPath $courseTemplatePath).Length -lt 1 -or
|
||||||
|
(Get-Item -LiteralPath $courseExportPath).Length -lt 1) {
|
||||||
|
throw 'Course Excel template or export is empty.'
|
||||||
|
}
|
||||||
|
$courseImport = Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri 'http://localhost:5255/api/courses/import' `
|
||||||
|
-Headers $headers `
|
||||||
|
-Form @{ file = Get-Item -LiteralPath $courseExportPath }
|
||||||
|
if ($courseImport.created -ne 0 -or
|
||||||
|
$courseImport.updated -ne $courses.total -or
|
||||||
|
$courseImport.total -ne $courses.total) {
|
||||||
|
throw 'Course Excel round-trip did not update the expected records.'
|
||||||
|
}
|
||||||
$curriculumPlans = Invoke-RestMethod -Uri 'http://localhost:5255/api/curriculum-plans?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) {
|
if ($curriculumPlans.total -gt 0) {
|
||||||
$curriculumDetail = Invoke-RestMethod `
|
$curriculumDetail = Invoke-RestMethod `
|
||||||
@@ -757,6 +791,8 @@ try {
|
|||||||
Teachers = $teachers.total
|
Teachers = $teachers.total
|
||||||
Students = $students.total
|
Students = $students.total
|
||||||
Courses = $courses.total
|
Courses = $courses.total
|
||||||
|
CourseCategories = @($courseCategories).Count
|
||||||
|
CourseExcelRows = $courseImport.total
|
||||||
Plans = $curriculumPlans.total
|
Plans = $curriculumPlans.total
|
||||||
PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 }
|
PlanModules = if ($null -ne $curriculumDetail) { @($curriculumDetail.modules).Count } else { 0 }
|
||||||
TeachingTasks = $teachingTasks.total
|
TeachingTasks = $teachingTasks.total
|
||||||
@@ -790,4 +826,6 @@ finally {
|
|||||||
if (-not $process.HasExited) {
|
if (-not $process.HasExited) {
|
||||||
Stop-Process -Id $process.Id -Force
|
Stop-Process -Id $process.Id -Force
|
||||||
}
|
}
|
||||||
|
Remove-Item -LiteralPath $courseTemplatePath -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $courseExportPath -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,44 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
|
|
||||||
|
[HttpGet("course-categories")]
|
||||||
|
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await db.CourseCategories.AsNoTracking()
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
[HttpPost("course-categories")]
|
||||||
|
[Authorize(Roles = Administrators)]
|
||||||
|
public async Task<ActionResult<CourseCategory>> CreateCourseCategory(
|
||||||
|
CatalogRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = new CourseCategory
|
||||||
|
{
|
||||||
|
Code = request.Code.Trim(),
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
IsEnabled = request.IsEnabled
|
||||||
|
};
|
||||||
|
return await CreateAsync(entity, nameof(GetCourseCategories), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("course-categories/{id:guid}")]
|
||||||
|
[Authorize(Roles = Administrators)]
|
||||||
|
public async Task<ActionResult<CourseCategory>> UpdateCourseCategory(
|
||||||
|
Guid id,
|
||||||
|
CatalogRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
|
||||||
|
if (entity is null) return NotFound();
|
||||||
|
ApplyCatalog(entity, request);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("majors")]
|
[HttpPost("majors")]
|
||||||
[Authorize(Roles = Administrators)]
|
[Authorize(Roles = Administrators)]
|
||||||
public async Task<ActionResult<Major>> CreateMajor(
|
public async Task<ActionResult<Major>> CreateMajor(
|
||||||
@@ -403,6 +441,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
|||||||
"terms" => await db.AcademicTerms.FindAsync([id], cancellationToken),
|
"terms" => await db.AcademicTerms.FindAsync([id], cancellationToken),
|
||||||
"buildings" => await db.Buildings.FindAsync([id], cancellationToken),
|
"buildings" => await db.Buildings.FindAsync([id], cancellationToken),
|
||||||
"classrooms" => await db.Classrooms.FindAsync([id], cancellationToken),
|
"classrooms" => await db.Classrooms.FindAsync([id], cancellationToken),
|
||||||
|
"course-categories" => await db.CourseCategories.FindAsync([id], cancellationToken),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
||||||
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
||||||
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
||||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"]
|
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
|
||||||
|
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
||||||
};
|
};
|
||||||
|
|
||||||
[HttpGet("{kind}/template")]
|
[HttpGet("{kind}/template")]
|
||||||
@@ -90,6 +91,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
|
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
|
||||||
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
|
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
|
||||||
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
|
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
|
||||||
|
"course-categories" => await ImportCourseCategoriesAsync(
|
||||||
|
rows, errors, cancellationToken),
|
||||||
_ => throw new InvalidOperationException()
|
_ => throw new InvalidOperationException()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,6 +150,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
||||||
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
||||||
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
x.Equipment, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
||||||
|
"course-categories" => (await db.CourseCategories.AsNoTracking()
|
||||||
|
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.Select(x => Row(x.Code, x.Name, x.SortOrder, Status(x.IsEnabled))).ToList(),
|
||||||
_ => []
|
_ => []
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -471,6 +478,36 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
return new(created, updated, rows.Count);
|
return new(created, updated, rows.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ExcelImportResult> ImportCourseCategoriesAsync(
|
||||||
|
IReadOnlyList<ExcelRow> rows,
|
||||||
|
List<string> errors,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await db.CourseCategories.ToDictionaryAsync(
|
||||||
|
x => x.Code,
|
||||||
|
StringComparer.OrdinalIgnoreCase,
|
||||||
|
cancellationToken);
|
||||||
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var code = Required(row, "编码", errors);
|
||||||
|
var name = Required(row, "名称", errors);
|
||||||
|
if (code is null || name is null) continue;
|
||||||
|
var entity = existing.GetValueOrDefault(code);
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = new CourseCategory { Code = code, Name = name };
|
||||||
|
db.CourseCategories.Add(entity);
|
||||||
|
existing[code] = entity;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
else updated++;
|
||||||
|
ApplyCatalog(entity, row, name, errors);
|
||||||
|
}
|
||||||
|
return new(created, updated, rows.Count);
|
||||||
|
}
|
||||||
|
|
||||||
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||||
{
|
{
|
||||||
foreach (var error in errors.Take(50))
|
foreach (var error in errors.Take(50))
|
||||||
@@ -582,6 +619,7 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
|||||||
"terms" => "学期",
|
"terms" => "学期",
|
||||||
"buildings" => "教学楼",
|
"buildings" => "教学楼",
|
||||||
"classrooms" => "教室",
|
"classrooms" => "教室",
|
||||||
|
"course-categories" => "课程分类",
|
||||||
_ => "基础数据"
|
_ => "基础数据"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class CoursesController(
|
|||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
string? keyword = null,
|
string? keyword = null,
|
||||||
Guid? collegeId = null,
|
Guid? collegeId = null,
|
||||||
|
Guid? categoryId = null,
|
||||||
CourseNature? nature = null,
|
CourseNature? nature = null,
|
||||||
bool? isEnabled = null,
|
bool? isEnabled = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -42,6 +43,8 @@ public sealed class CoursesController(
|
|||||||
var source = ScopedCourses().AsNoTracking();
|
var source = ScopedCourses().AsNoTracking();
|
||||||
if (collegeId.HasValue)
|
if (collegeId.HasValue)
|
||||||
source = source.Where(x => x.CollegeId == collegeId.Value);
|
source = source.Where(x => x.CollegeId == collegeId.Value);
|
||||||
|
if (categoryId.HasValue)
|
||||||
|
source = source.Where(x => x.CourseCategoryId == categoryId.Value);
|
||||||
if (nature.HasValue)
|
if (nature.HasValue)
|
||||||
source = source.Where(x => x.Nature == nature.Value);
|
source = source.Where(x => x.Nature == nature.Value);
|
||||||
if (isEnabled.HasValue)
|
if (isEnabled.HasValue)
|
||||||
@@ -69,6 +72,9 @@ public sealed class CoursesController(
|
|||||||
x.EnglishName,
|
x.EnglishName,
|
||||||
x.CollegeId,
|
x.CollegeId,
|
||||||
CollegeName = x.College!.Name,
|
CollegeName = x.College!.Name,
|
||||||
|
x.CourseCategoryId,
|
||||||
|
CategoryCode = x.CourseCategory != null ? x.CourseCategory.Code : null,
|
||||||
|
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
||||||
x.Credits,
|
x.Credits,
|
||||||
x.TotalHours,
|
x.TotalHours,
|
||||||
x.LectureHours,
|
x.LectureHours,
|
||||||
@@ -106,6 +112,7 @@ public sealed class CoursesController(
|
|||||||
Name = request.Name.Trim(),
|
Name = request.Name.Trim(),
|
||||||
EnglishName = Normalize(request.EnglishName),
|
EnglishName = Normalize(request.EnglishName),
|
||||||
CollegeId = request.CollegeId,
|
CollegeId = request.CollegeId,
|
||||||
|
CourseCategoryId = request.CourseCategoryId,
|
||||||
Credits = request.Credits,
|
Credits = request.Credits,
|
||||||
TotalHours = request.TotalHours,
|
TotalHours = request.TotalHours,
|
||||||
LectureHours = request.LectureHours,
|
LectureHours = request.LectureHours,
|
||||||
@@ -137,6 +144,7 @@ public sealed class CoursesController(
|
|||||||
entity.Name = request.Name.Trim();
|
entity.Name = request.Name.Trim();
|
||||||
entity.EnglishName = Normalize(request.EnglishName);
|
entity.EnglishName = Normalize(request.EnglishName);
|
||||||
entity.CollegeId = request.CollegeId;
|
entity.CollegeId = request.CollegeId;
|
||||||
|
entity.CourseCategoryId = request.CourseCategoryId;
|
||||||
entity.Credits = request.Credits;
|
entity.Credits = request.Credits;
|
||||||
entity.TotalHours = request.TotalHours;
|
entity.TotalHours = request.TotalHours;
|
||||||
entity.LectureHours = request.LectureHours;
|
entity.LectureHours = request.LectureHours;
|
||||||
@@ -167,6 +175,10 @@ public sealed class CoursesController(
|
|||||||
if (!CanManage(request.CollegeId, request.Nature)) return Forbid();
|
if (!CanManage(request.CollegeId, request.Nature)) return Forbid();
|
||||||
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
|
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
|
||||||
return ValidationProblem("所选学院不存在。");
|
return ValidationProblem("所选学院不存在。");
|
||||||
|
if (!await db.CourseCategories.AnyAsync(
|
||||||
|
x => x.Id == request.CourseCategoryId && x.IsEnabled,
|
||||||
|
cancellationToken))
|
||||||
|
return ValidationProblem("所选课程分类不存在或已停用。");
|
||||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||||
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
||||||
return null;
|
return null;
|
||||||
@@ -242,6 +254,7 @@ public sealed record CourseRequest(
|
|||||||
[Required, MaxLength(100)] string Name,
|
[Required, MaxLength(100)] string Name,
|
||||||
[MaxLength(150)] string? EnglishName,
|
[MaxLength(150)] string? EnglishName,
|
||||||
Guid CollegeId,
|
Guid CollegeId,
|
||||||
|
Guid CourseCategoryId,
|
||||||
[Range(typeof(decimal), "0.1", "99")] decimal Credits,
|
[Range(typeof(decimal), "0.1", "99")] decimal Credits,
|
||||||
[Range(1, 1000)] int TotalHours,
|
[Range(1, 1000)] int TotalHours,
|
||||||
[Range(0, 1000)] int LectureHours,
|
[Range(0, 1000)] int LectureHours,
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Excel;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/courses")]
|
||||||
|
public sealed class CoursesExcelController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string WriteRoles =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin;
|
||||||
|
|
||||||
|
private static readonly string[] Headers =
|
||||||
|
[
|
||||||
|
"课程编码", "课程名称", "英文名称", "开课学院编码", "课程分类编码",
|
||||||
|
"课程性质", "学分", "总学时", "讲授学时", "实践学时",
|
||||||
|
"考核方式", "课程简介", "启用状态", "排序"
|
||||||
|
];
|
||||||
|
|
||||||
|
[HttpGet("template")]
|
||||||
|
[Authorize(Roles = WriteRoles)]
|
||||||
|
public IActionResult DownloadTemplate()
|
||||||
|
{
|
||||||
|
var bytes = ExcelWorkbookHelper.Create(
|
||||||
|
"课程库导入",
|
||||||
|
Headers,
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
"请勿修改第一行列名;每行填写一门课程,课程编码是全校唯一标识。",
|
||||||
|
"开课学院编码、课程分类编码必须已在系统中存在;课程分类还必须处于启用状态。",
|
||||||
|
"课程性质请填写:通识必修、专业必修、专业选修、通识选修或实践课程。",
|
||||||
|
"考核方式请填写考试或考查;启用状态请填写是或否。",
|
||||||
|
"课程编码已存在时更新课程,不存在时新增课程。",
|
||||||
|
"学院管理员只能维护本学院的专业必修、专业选修和实践课程。",
|
||||||
|
"讲授学时与实践学时之和不能超过总学时;整批任一行有误时均不会写入。"
|
||||||
|
]);
|
||||||
|
return File(
|
||||||
|
bytes,
|
||||||
|
ExcelWorkbookHelper.ContentType,
|
||||||
|
"课程库导入模板.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("export")]
|
||||||
|
public async Task<IActionResult> Export(
|
||||||
|
string? keyword = null,
|
||||||
|
Guid? collegeId = null,
|
||||||
|
Guid? categoryId = null,
|
||||||
|
CourseNature? nature = null,
|
||||||
|
bool? isEnabled = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var source = ApplyFilters(
|
||||||
|
ScopedCourses().AsNoTracking(),
|
||||||
|
keyword,
|
||||||
|
collegeId,
|
||||||
|
categoryId,
|
||||||
|
nature,
|
||||||
|
isEnabled);
|
||||||
|
var courses = await source
|
||||||
|
.Include(x => x.College)
|
||||||
|
.Include(x => x.CourseCategory)
|
||||||
|
.OrderBy(x => x.SortOrder)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var rows = courses
|
||||||
|
.Select(x => Row(
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.EnglishName,
|
||||||
|
x.College!.Code,
|
||||||
|
x.CourseCategory?.Code,
|
||||||
|
NatureName(x.Nature),
|
||||||
|
x.Credits,
|
||||||
|
x.TotalHours,
|
||||||
|
x.LectureHours,
|
||||||
|
x.PracticeHours,
|
||||||
|
AssessmentName(x.AssessmentMethod),
|
||||||
|
x.Description,
|
||||||
|
x.IsEnabled ? "是" : "否",
|
||||||
|
x.SortOrder))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var bytes = ExcelWorkbookHelper.Create("课程库", Headers, rows);
|
||||||
|
return File(
|
||||||
|
bytes,
|
||||||
|
ExcelWorkbookHelper.ContentType,
|
||||||
|
$"课程库-{DateTime.Now:yyyyMMdd-HHmm}.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("import")]
|
||||||
|
[Authorize(Roles = WriteRoles)]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<ActionResult<ExcelImportResult>> Import(
|
||||||
|
IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ExcelRow> rows;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||||
|
file,
|
||||||
|
Headers,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException exception)
|
||||||
|
{
|
||||||
|
return ValidationProblem(exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
return ValidationProblem("Excel 中没有可导入的课程数据。");
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
await using var transaction =
|
||||||
|
await db.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await ImportRowsAsync(rows, errors, cancellationToken);
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return ImportValidationProblem(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken);
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "导入失败",
|
||||||
|
Detail = "存在重复课程编码或无效关联,未写入任何课程。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ExcelImportResult> ImportRowsAsync(
|
||||||
|
IReadOnlyList<ExcelRow> rows,
|
||||||
|
List<string> errors,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await db.Courses.ToDictionaryAsync(
|
||||||
|
x => x.Code,
|
||||||
|
StringComparer.OrdinalIgnoreCase,
|
||||||
|
cancellationToken);
|
||||||
|
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
|
||||||
|
x => x.Code,
|
||||||
|
StringComparer.OrdinalIgnoreCase,
|
||||||
|
cancellationToken);
|
||||||
|
var categories = await db.CourseCategories.AsNoTracking().ToDictionaryAsync(
|
||||||
|
x => x.Code,
|
||||||
|
StringComparer.OrdinalIgnoreCase,
|
||||||
|
cancellationToken);
|
||||||
|
var importedCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var created = 0;
|
||||||
|
var updated = 0;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var code = Required(row, "课程编码", 40, errors);
|
||||||
|
var name = Required(row, "课程名称", 100, errors);
|
||||||
|
var englishName = Optional(row, "英文名称", 150, errors);
|
||||||
|
var collegeCode = Required(row, "开课学院编码", 40, errors);
|
||||||
|
var categoryCode = Required(row, "课程分类编码", 40, errors);
|
||||||
|
var nature = ParseNature(row, errors);
|
||||||
|
var credits = ParseDecimal(row, "学分", 0.1m, 99m, errors);
|
||||||
|
var totalHours = ParseInt(row, "总学时", 1, 1000, errors);
|
||||||
|
var lectureHours = ParseInt(row, "讲授学时", 0, 1000, errors);
|
||||||
|
var practiceHours = ParseInt(row, "实践学时", 0, 1000, errors);
|
||||||
|
var assessment = ParseAssessment(row, errors);
|
||||||
|
var description = Optional(row, "课程简介", 1000, errors);
|
||||||
|
var enabled = ParseEnabled(row, errors);
|
||||||
|
var sortOrder = ParseInt(row, "排序", 0, int.MaxValue, errors);
|
||||||
|
|
||||||
|
if (code is null || name is null || collegeCode is null ||
|
||||||
|
categoryCode is null || nature is null || credits is null ||
|
||||||
|
totalHours is null || lectureHours is null ||
|
||||||
|
practiceHours is null || assessment is null ||
|
||||||
|
enabled is null || sortOrder is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!importedCodes.Add(code))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:课程编码“{code}”在文件中重复。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!colleges.TryGetValue(collegeCode, out var college))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:开课学院编码“{collegeCode}”不存在。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!categories.TryGetValue(categoryCode, out var category))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:课程分类编码“{categoryCode}”不存在。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!category.IsEnabled)
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:课程分类“{category.Name}”已停用。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lectureHours.Value + practiceHours.Value > totalHours.Value)
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:讲授学时与实践学时之和不能超过总学时。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!CanManage(college.Id, nature.Value))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:无权维护学院“{college.Name}”的“{NatureName(nature.Value)}”课程。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = existing.GetValueOrDefault(code);
|
||||||
|
if (entity is not null && !CanManage(entity.CollegeId, entity.Nature))
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:课程编码“{code}”已属于无权维护的课程。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = new Course
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
CollegeId = college.Id
|
||||||
|
};
|
||||||
|
db.Courses.Add(entity);
|
||||||
|
existing[code] = entity;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Name = name;
|
||||||
|
entity.EnglishName = englishName;
|
||||||
|
entity.CollegeId = college.Id;
|
||||||
|
entity.CourseCategoryId = category.Id;
|
||||||
|
entity.Credits = credits.Value;
|
||||||
|
entity.TotalHours = totalHours.Value;
|
||||||
|
entity.LectureHours = lectureHours.Value;
|
||||||
|
entity.PracticeHours = practiceHours.Value;
|
||||||
|
entity.Nature = nature.Value;
|
||||||
|
entity.AssessmentMethod = assessment.Value;
|
||||||
|
entity.Description = description;
|
||||||
|
entity.IsEnabled = enabled.Value;
|
||||||
|
entity.SortOrder = sortOrder.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(created, updated, rows.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Course> ScopedCourses()
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
var source = db.Courses.AsQueryable();
|
||||||
|
if (scope.Scope == DataScope.All) return source;
|
||||||
|
if (scope.Scope == DataScope.College)
|
||||||
|
{
|
||||||
|
return source.Where(x =>
|
||||||
|
x.Nature == CourseNature.GeneralRequired ||
|
||||||
|
x.Nature == CourseNature.GeneralElective ||
|
||||||
|
x.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
}
|
||||||
|
if (scope.Scope == DataScope.Class)
|
||||||
|
{
|
||||||
|
return source.Where(course => db.TeachingTasks.Any(task =>
|
||||||
|
task.CourseId == course.Id &&
|
||||||
|
task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.CounselorUserId == scope.UserId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var userId = scope.UserId;
|
||||||
|
var isTeacher = scope.IsInRole(SystemRoles.Teacher);
|
||||||
|
var isStudent = scope.IsInRole(SystemRoles.Student);
|
||||||
|
return source.Where(course =>
|
||||||
|
isTeacher && db.TeachingTasks.Any(task =>
|
||||||
|
task.CourseId == course.Id &&
|
||||||
|
task.Teachers.Any(item => item.Teacher!.UserId == userId)) ||
|
||||||
|
isStudent && db.TeachingTasks.Any(task =>
|
||||||
|
task.CourseId == course.Id &&
|
||||||
|
task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.UserId == userId))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<Course> ApplyFilters(
|
||||||
|
IQueryable<Course> source,
|
||||||
|
string? keyword,
|
||||||
|
Guid? collegeId,
|
||||||
|
Guid? categoryId,
|
||||||
|
CourseNature? nature,
|
||||||
|
bool? isEnabled)
|
||||||
|
{
|
||||||
|
if (collegeId.HasValue)
|
||||||
|
source = source.Where(x => x.CollegeId == collegeId.Value);
|
||||||
|
if (categoryId.HasValue)
|
||||||
|
source = source.Where(x => x.CourseCategoryId == categoryId.Value);
|
||||||
|
if (nature.HasValue)
|
||||||
|
source = source.Where(x => x.Nature == nature.Value);
|
||||||
|
if (isEnabled.HasValue)
|
||||||
|
source = source.Where(x => x.IsEnabled == isEnabled.Value);
|
||||||
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
|
{
|
||||||
|
keyword = keyword.Trim();
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.Code.Contains(keyword) ||
|
||||||
|
x.Name.Contains(keyword) ||
|
||||||
|
(x.EnglishName != null && x.EnglishName.Contains(keyword)));
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanManage(Guid collegeId, CourseNature nature) =>
|
||||||
|
CourseMaintenancePolicy.CanManage(
|
||||||
|
currentUserDataScope.Current,
|
||||||
|
collegeId,
|
||||||
|
nature);
|
||||||
|
|
||||||
|
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
|
||||||
|
{
|
||||||
|
foreach (var error in errors.Take(50))
|
||||||
|
ModelState.AddModelError("file", error);
|
||||||
|
if (errors.Count > 50)
|
||||||
|
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
|
||||||
|
return ValidationProblem(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Required(
|
||||||
|
ExcelRow row,
|
||||||
|
string header,
|
||||||
|
int maximumLength,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
var value = Optional(row, header, maximumLength, errors);
|
||||||
|
if (value is not null) return value;
|
||||||
|
if (string.IsNullOrWhiteSpace(row[header]))
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“{header}”不能为空。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Optional(
|
||||||
|
ExcelRow row,
|
||||||
|
string header,
|
||||||
|
int maximumLength,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
var value = string.IsNullOrWhiteSpace(row[header])
|
||||||
|
? null
|
||||||
|
: row[header].Trim();
|
||||||
|
if (value is null || value.Length <= maximumLength) return value;
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:“{header}”不能超过 {maximumLength} 个字符。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseInt(
|
||||||
|
ExcelRow row,
|
||||||
|
string header,
|
||||||
|
int minimum,
|
||||||
|
int maximum,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
if (int.TryParse(
|
||||||
|
row[header],
|
||||||
|
NumberStyles.Integer,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var value) &&
|
||||||
|
value >= minimum &&
|
||||||
|
value <= maximum)
|
||||||
|
return value;
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:“{header}”必须是 {minimum} 至 {maximum} 的整数。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal? ParseDecimal(
|
||||||
|
ExcelRow row,
|
||||||
|
string header,
|
||||||
|
decimal minimum,
|
||||||
|
decimal maximum,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
if (decimal.TryParse(
|
||||||
|
row[header],
|
||||||
|
NumberStyles.Number,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out var value) &&
|
||||||
|
value >= minimum &&
|
||||||
|
value <= maximum)
|
||||||
|
return value;
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:“{header}”必须是 {minimum:0.##} 至 {maximum:0.##} 的数值。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CourseNature? ParseNature(ExcelRow row, List<string> errors)
|
||||||
|
{
|
||||||
|
var value = row["课程性质"];
|
||||||
|
if (value == "通识必修" ||
|
||||||
|
value.Equals(nameof(CourseNature.GeneralRequired), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return CourseNature.GeneralRequired;
|
||||||
|
if (value == "专业必修" ||
|
||||||
|
value.Equals(nameof(CourseNature.MajorRequired), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return CourseNature.MajorRequired;
|
||||||
|
if (value == "专业选修" ||
|
||||||
|
value.Equals(nameof(CourseNature.MajorElective), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return CourseNature.MajorElective;
|
||||||
|
if (value == "通识选修" ||
|
||||||
|
value.Equals(nameof(CourseNature.GeneralElective), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return CourseNature.GeneralElective;
|
||||||
|
if (value is "实践" or "实践课程" ||
|
||||||
|
value.Equals(nameof(CourseNature.Practice), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return CourseNature.Practice;
|
||||||
|
errors.Add(
|
||||||
|
$"第 {row.RowNumber} 行:“课程性质”请填写通识必修、专业必修、专业选修、通识选修或实践课程。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssessmentMethod? ParseAssessment(
|
||||||
|
ExcelRow row,
|
||||||
|
List<string> errors)
|
||||||
|
{
|
||||||
|
var value = row["考核方式"];
|
||||||
|
if (value == "考试" ||
|
||||||
|
value.Equals(nameof(AssessmentMethod.Examination), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return AssessmentMethod.Examination;
|
||||||
|
if (value == "考查" ||
|
||||||
|
value.Equals(nameof(AssessmentMethod.Assessment), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return AssessmentMethod.Assessment;
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“考核方式”请填写考试或考查。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool? ParseEnabled(ExcelRow row, List<string> errors)
|
||||||
|
{
|
||||||
|
var value = row["启用状态"];
|
||||||
|
if (value is "是" or "启用" or "1" ||
|
||||||
|
value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
if (value is "否" or "停用" or "0" ||
|
||||||
|
value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:“启用状态”请填写是或否。");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<object?> Row(params object?[] values) => values;
|
||||||
|
|
||||||
|
private static string NatureName(CourseNature value) => value switch
|
||||||
|
{
|
||||||
|
CourseNature.GeneralRequired => "通识必修",
|
||||||
|
CourseNature.MajorRequired => "专业必修",
|
||||||
|
CourseNature.MajorElective => "专业选修",
|
||||||
|
CourseNature.GeneralElective => "通识选修",
|
||||||
|
_ => "实践课程"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string AssessmentName(AssessmentMethod value) =>
|
||||||
|
value == AssessmentMethod.Examination ? "考试" : "考查";
|
||||||
|
}
|
||||||
@@ -168,6 +168,9 @@ public sealed class CurriculumPlansController(
|
|||||||
course.Course.Credits,
|
course.Course.Credits,
|
||||||
course.Course.TotalHours,
|
course.Course.TotalHours,
|
||||||
course.Course.Nature,
|
course.Course.Nature,
|
||||||
|
CategoryName = course.Course.CourseCategory != null
|
||||||
|
? course.Course.CourseCategory.Name
|
||||||
|
: null,
|
||||||
course.RecommendedSemester,
|
course.RecommendedSemester,
|
||||||
course.Type,
|
course.Type,
|
||||||
course.Notes
|
course.Notes
|
||||||
|
|||||||
@@ -156,10 +156,6 @@ public sealed class GraduationAuditsController(
|
|||||||
{
|
{
|
||||||
var plan = plans.FirstOrDefault(x =>
|
var plan = plans.FirstOrDefault(x =>
|
||||||
x.MajorId == student.AdministrativeClass!.MajorId);
|
x.MajorId == student.AdministrativeClass!.MajorId);
|
||||||
var requiredCourses = plan?.Modules
|
|
||||||
.SelectMany(x => x.Courses)
|
|
||||||
.Where(x => x.Type == CurriculumCourseType.Required)
|
|
||||||
.ToList() ?? [];
|
|
||||||
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
|
var studentGrades = grades.Where(x => x.StudentId == student.Id).ToList();
|
||||||
var passedCourseIds = studentGrades
|
var passedCourseIds = studentGrades
|
||||||
.Where(IsPassed)
|
.Where(IsPassed)
|
||||||
@@ -170,14 +166,12 @@ public sealed class GraduationAuditsController(
|
|||||||
.Where(IsPassed)
|
.Where(IsPassed)
|
||||||
.GroupBy(x => x.CourseId)
|
.GroupBy(x => x.CourseId)
|
||||||
.Sum(x => x.Max(item => item.Credits));
|
.Sum(x => x.Max(item => item.Credits));
|
||||||
var missingCourses = requiredCourses
|
var completion = CurriculumCompletionRules.Evaluate(
|
||||||
.Where(x => !passedCourseIds.Contains(x.CourseId))
|
plan?.Modules ?? [],
|
||||||
.Select(x => x.Course!.Name)
|
passedCourseIds);
|
||||||
.Distinct()
|
|
||||||
.ToArray();
|
|
||||||
var missingCourseNames = plan is null
|
var missingCourseNames = plan is null
|
||||||
? "未匹配已发布的培养方案"
|
? "未匹配已发布的培养方案"
|
||||||
: string.Join("、", missingCourses);
|
: string.Join("、", completion.MissingRequirements);
|
||||||
if (missingCourseNames.Length > 2000)
|
if (missingCourseNames.Length > 2000)
|
||||||
missingCourseNames = missingCourseNames[..2000];
|
missingCourseNames = missingCourseNames[..2000];
|
||||||
var failedCourseCount = studentGrades
|
var failedCourseCount = studentGrades
|
||||||
@@ -188,8 +182,8 @@ public sealed class GraduationAuditsController(
|
|||||||
student.Status,
|
student.Status,
|
||||||
plan?.TotalCredits ?? 0,
|
plan?.TotalCredits ?? 0,
|
||||||
earnedCredits,
|
earnedCredits,
|
||||||
requiredCourses.Count,
|
completion.RequirementCount,
|
||||||
requiredCourses.Count - missingCourses.Length,
|
completion.PassedRequirementCount,
|
||||||
failedCourseCount);
|
failedCourseCount);
|
||||||
db.GraduationAuditResults.Add(new GraduationAuditResult
|
db.GraduationAuditResults.Add(new GraduationAuditResult
|
||||||
{
|
{
|
||||||
@@ -199,8 +193,8 @@ public sealed class GraduationAuditsController(
|
|||||||
StudentStatusSnapshot = student.Status,
|
StudentStatusSnapshot = student.Status,
|
||||||
RequiredCredits = plan?.TotalCredits ?? 0,
|
RequiredCredits = plan?.TotalCredits ?? 0,
|
||||||
EarnedCredits = earnedCredits,
|
EarnedCredits = earnedCredits,
|
||||||
RequiredCourseCount = requiredCourses.Count,
|
RequiredCourseCount = completion.RequirementCount,
|
||||||
PassedRequiredCourseCount = requiredCourses.Count - missingCourses.Length,
|
PassedRequiredCourseCount = completion.PassedRequirementCount,
|
||||||
FailedCourseCount = failedCourseCount,
|
FailedCourseCount = failedCourseCount,
|
||||||
MissingCourseNames = missingCourseNames,
|
MissingCourseNames = missingCourseNames,
|
||||||
CalculatedConclusion = conclusion,
|
CalculatedConclusion = conclusion,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public sealed class Course : CatalogEntity
|
|||||||
{
|
{
|
||||||
public Guid CollegeId { get; set; }
|
public Guid CollegeId { get; set; }
|
||||||
public College? College { get; set; }
|
public College? College { get; set; }
|
||||||
|
public Guid? CourseCategoryId { get; set; }
|
||||||
|
public CourseCategory? CourseCategory { get; set; }
|
||||||
public string? EnglishName { get; set; }
|
public string? EnglishName { get; set; }
|
||||||
public decimal Credits { get; set; }
|
public decimal Credits { get; set; }
|
||||||
public int TotalHours { get; set; }
|
public int TotalHours { get; set; }
|
||||||
@@ -50,6 +52,10 @@ public sealed class Course : CatalogEntity
|
|||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class CourseCategory : CatalogEntity
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public enum Gender
|
public enum Gender
|
||||||
{
|
{
|
||||||
Unknown = 0,
|
Unknown = 0,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Graduation;
|
||||||
|
|
||||||
|
public sealed record CurriculumCompletionSummary(
|
||||||
|
int RequirementCount,
|
||||||
|
int PassedRequirementCount,
|
||||||
|
IReadOnlyList<string> MissingRequirements);
|
||||||
|
|
||||||
|
public static class CurriculumCompletionRules
|
||||||
|
{
|
||||||
|
public static CurriculumCompletionSummary Evaluate(
|
||||||
|
IEnumerable<CurriculumModule> modules,
|
||||||
|
IReadOnlySet<Guid> passedCourseIds)
|
||||||
|
{
|
||||||
|
var moduleList = modules.ToList();
|
||||||
|
var requiredCourses = moduleList
|
||||||
|
.SelectMany(x => x.Courses)
|
||||||
|
.Where(x => x.Type == CurriculumCourseType.Required)
|
||||||
|
.ToList();
|
||||||
|
var optionGroups = moduleList
|
||||||
|
.Where(x =>
|
||||||
|
x.RequiredCredits > 0 &&
|
||||||
|
x.Courses.Any(course => course.Type == CurriculumCourseType.Elective))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var missing = requiredCourses
|
||||||
|
.Where(x => !passedCourseIds.Contains(x.CourseId))
|
||||||
|
.Select(x => x.Course?.Name ?? "未命名必修课程")
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var passedRequiredCourses = requiredCourses.Count -
|
||||||
|
requiredCourses.Count(x =>
|
||||||
|
!passedCourseIds.Contains(x.CourseId));
|
||||||
|
var passedGroups = 0;
|
||||||
|
foreach (var module in optionGroups)
|
||||||
|
{
|
||||||
|
var earnedGroupCredits = module.Courses
|
||||||
|
.Where(x => passedCourseIds.Contains(x.CourseId))
|
||||||
|
.GroupBy(x => x.CourseId)
|
||||||
|
.Sum(group => group.Max(x => x.Course?.Credits ?? 0));
|
||||||
|
if (earnedGroupCredits >= module.RequiredCredits)
|
||||||
|
{
|
||||||
|
passedGroups++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortage = module.RequiredCredits - earnedGroupCredits;
|
||||||
|
missing.Add($"{module.Name}(还差 {shortage:0.#} 学分)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CurriculumCompletionSummary(
|
||||||
|
requiredCourses.Count + optionGroups.Count,
|
||||||
|
passedRequiredCourses + passedGroups,
|
||||||
|
missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<AcademicTerm> AcademicTerms => Set<AcademicTerm>();
|
public DbSet<AcademicTerm> AcademicTerms => Set<AcademicTerm>();
|
||||||
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<CourseCategory> CourseCategories => Set<CourseCategory>();
|
||||||
public DbSet<Course> Courses => Set<Course>();
|
public DbSet<Course> Courses => Set<Course>();
|
||||||
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
|
||||||
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
|
||||||
@@ -78,6 +79,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
ConfigureCatalog<Building>(builder);
|
ConfigureCatalog<Building>(builder);
|
||||||
ConfigureCatalog<Classroom>(builder);
|
ConfigureCatalog<Classroom>(builder);
|
||||||
ConfigureCatalog<AcademicTerm>(builder);
|
ConfigureCatalog<AcademicTerm>(builder);
|
||||||
|
ConfigureCatalog<CourseCategory>(builder);
|
||||||
ConfigureCatalog<Course>(builder);
|
ConfigureCatalog<Course>(builder);
|
||||||
|
|
||||||
builder.Entity<College>()
|
builder.Entity<College>()
|
||||||
@@ -167,10 +169,15 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.Property(x => x.Credits).HasPrecision(5, 2);
|
entity.Property(x => x.Credits).HasPrecision(5, 2);
|
||||||
entity.Property(x => x.Description).HasMaxLength(1000);
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
entity.HasIndex(x => new { x.CollegeId, x.Nature });
|
entity.HasIndex(x => new { x.CollegeId, x.Nature });
|
||||||
|
entity.HasIndex(x => x.CourseCategoryId);
|
||||||
entity.HasOne(x => x.College)
|
entity.HasOne(x => x.College)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(x => x.CollegeId)
|
.HasForeignKey(x => x.CollegeId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.CourseCategory)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CourseCategoryId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<CurriculumPlan>(entity =>
|
builder.Entity<CurriculumPlan>(entity =>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class DatabaseInitializer(
|
|||||||
|
|
||||||
await SeedRolesAsync();
|
await SeedRolesAsync();
|
||||||
await SeedAdministratorAsync();
|
await SeedAdministratorAsync();
|
||||||
|
await SeedCourseCategoriesAsync();
|
||||||
|
|
||||||
if (environment.IsDevelopment())
|
if (environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -108,6 +109,55 @@ public sealed class DatabaseInitializer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SeedCourseCategoriesAsync()
|
||||||
|
{
|
||||||
|
var defaults = new[]
|
||||||
|
{
|
||||||
|
("BASIC", "基础课程", 10),
|
||||||
|
("MORAL", "德育课程", 20),
|
||||||
|
("AESTHETIC", "美育课程", 30),
|
||||||
|
("LABOR", "劳动教育", 40),
|
||||||
|
("INNOVATION", "创新创业", 50),
|
||||||
|
("ENGLISH", "大学英语", 60),
|
||||||
|
("SPORTS", "大学体育", 70),
|
||||||
|
("MILITARY", "国防教育", 80),
|
||||||
|
("MAJOR", "专业教育", 90),
|
||||||
|
("PRACTICE", "实践教学", 100)
|
||||||
|
};
|
||||||
|
var existingCodes = (await db.CourseCategories
|
||||||
|
.Select(x => x.Code)
|
||||||
|
.ToListAsync())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var (code, name, sortOrder) in defaults)
|
||||||
|
{
|
||||||
|
if (existingCodes.Contains(code)) continue;
|
||||||
|
db.CourseCategories.Add(new CourseCategory
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
SortOrder = sortOrder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var categories = await db.CourseCategories
|
||||||
|
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var uncategorizedCourses = await db.Courses
|
||||||
|
.Where(x => x.CourseCategoryId == null)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var course in uncategorizedCourses)
|
||||||
|
{
|
||||||
|
var categoryCode = course.Nature switch
|
||||||
|
{
|
||||||
|
CourseNature.Practice => "PRACTICE",
|
||||||
|
CourseNature.MajorRequired or CourseNature.MajorElective => "MAJOR",
|
||||||
|
_ => "BASIC"
|
||||||
|
};
|
||||||
|
course.CourseCategoryId = categories[categoryCode].Id;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SeedDevelopmentDataAsync()
|
private async Task SeedDevelopmentDataAsync()
|
||||||
{
|
{
|
||||||
if (!await db.Campuses.AnyAsync())
|
if (!await db.Campuses.AnyAsync())
|
||||||
@@ -242,6 +292,9 @@ public sealed class DatabaseInitializer(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var courseCategories = await db.CourseCategories
|
||||||
|
.ToDictionaryAsync(x => x.Code, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!await db.Courses.AnyAsync())
|
if (!await db.Courses.AnyAsync())
|
||||||
{
|
{
|
||||||
db.Courses.AddRange(
|
db.Courses.AddRange(
|
||||||
@@ -251,6 +304,7 @@ public sealed class DatabaseInitializer(
|
|||||||
Name = "程序设计基础",
|
Name = "程序设计基础",
|
||||||
EnglishName = "Fundamentals of Programming",
|
EnglishName = "Fundamentals of Programming",
|
||||||
CollegeId = computerCollege.Id,
|
CollegeId = computerCollege.Id,
|
||||||
|
CourseCategoryId = courseCategories["BASIC"].Id,
|
||||||
Credits = 4,
|
Credits = 4,
|
||||||
TotalHours = 64,
|
TotalHours = 64,
|
||||||
LectureHours = 40,
|
LectureHours = 40,
|
||||||
@@ -265,6 +319,7 @@ public sealed class DatabaseInitializer(
|
|||||||
Name = "数据结构",
|
Name = "数据结构",
|
||||||
EnglishName = "Data Structures",
|
EnglishName = "Data Structures",
|
||||||
CollegeId = computerCollege.Id,
|
CollegeId = computerCollege.Id,
|
||||||
|
CourseCategoryId = courseCategories["BASIC"].Id,
|
||||||
Credits = 3.5m,
|
Credits = 3.5m,
|
||||||
TotalHours = 56,
|
TotalHours = 56,
|
||||||
LectureHours = 40,
|
LectureHours = 40,
|
||||||
@@ -278,6 +333,7 @@ public sealed class DatabaseInitializer(
|
|||||||
Name = "软件工程实践",
|
Name = "软件工程实践",
|
||||||
EnglishName = "Software Engineering Practice",
|
EnglishName = "Software Engineering Practice",
|
||||||
CollegeId = computerCollege.Id,
|
CollegeId = computerCollege.Id,
|
||||||
|
CourseCategoryId = courseCategories["PRACTICE"].Id,
|
||||||
Credits = 2,
|
Credits = 2,
|
||||||
TotalHours = 48,
|
TotalHours = 48,
|
||||||
LectureHours = 8,
|
LectureHours = 8,
|
||||||
@@ -286,7 +342,6 @@ public sealed class DatabaseInitializer(
|
|||||||
AssessmentMethod = AssessmentMethod.Assessment
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
if (!await db.CurriculumPlans.AnyAsync())
|
if (!await db.CurriculumPlans.AnyAsync())
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
|
private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
|
||||||
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
|
private const string DegreeAwardsMigration = "20260724_11_degree_awards";
|
||||||
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
|
private const string GraduationClearanceMigration = "20260724_12_graduation_clearance";
|
||||||
|
private const string CourseCategoriesMigration = "20260724_13_course_categories";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -90,6 +91,22 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
GraduationClearanceMigration,
|
GraduationClearanceMigration,
|
||||||
GraduationClearanceStatements,
|
GraduationClearanceStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var courseCategoryColumnExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('Courses')
|
||||||
|
WHERE name = 'CourseCategoryId'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
CourseCategoriesMigration,
|
||||||
|
courseCategoryColumnExists
|
||||||
|
? CourseCategoriesStatements.Where(
|
||||||
|
statement => !statement.StartsWith(
|
||||||
|
"ALTER TABLE", StringComparison.OrdinalIgnoreCase))
|
||||||
|
: CourseCategoriesStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -767,4 +784,34 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_GraduationClearanceItemId_StudentId" ON "GraduationClearanceRecords" ("GraduationClearanceItemId", "StudentId");""",
|
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_GraduationClearanceItemId_StudentId" ON "GraduationClearanceRecords" ("GraduationClearanceItemId", "StudentId");""",
|
||||||
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_StudentId_Status" ON "GraduationClearanceRecords" ("StudentId", "Status");"""
|
"""CREATE INDEX IF NOT EXISTS "IX_GraduationClearanceRecords_StudentId_Status" ON "GraduationClearanceRecords" ("StudentId", "Status");"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] CourseCategoriesStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "CourseCategories" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_CourseCategories" PRIMARY KEY,
|
||||||
|
"Code" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"SortOrder" INTEGER NOT NULL,
|
||||||
|
"IsEnabled" INTEGER NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseCategories_Code"
|
||||||
|
ON "CourseCategories" ("Code");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_CourseCategories_IsEnabled_SortOrder"
|
||||||
|
ON "CourseCategories" ("IsEnabled", "SortOrder");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE "Courses" ADD COLUMN "CourseCategoryId" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_Courses_CourseCategoryId"
|
||||||
|
ON "Courses" ("CourseCategoryId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+2541
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class CourseCategories : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CourseCategoryId",
|
||||||
|
table: "Courses",
|
||||||
|
type: "char(36)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CourseCategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "tinyint(1)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CourseCategories", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Courses_CourseCategoryId",
|
||||||
|
table: "Courses",
|
||||||
|
column: "CourseCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseCategories_Code",
|
||||||
|
table: "CourseCategories",
|
||||||
|
column: "Code",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CourseCategories_IsEnabled_SortOrder",
|
||||||
|
table: "CourseCategories",
|
||||||
|
columns: new[] { "IsEnabled", "SortOrder" });
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Courses_CourseCategories_CourseCategoryId",
|
||||||
|
table: "Courses",
|
||||||
|
column: "CourseCategoryId",
|
||||||
|
principalTable: "CourseCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Courses_CourseCategories_CourseCategoryId",
|
||||||
|
table: "Courses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CourseCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Courses_CourseCategoryId",
|
||||||
|
table: "Courses");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CourseCategoryId",
|
||||||
|
table: "Courses");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -329,6 +329,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.Property<Guid>("CollegeId")
|
b.Property<Guid>("CollegeId")
|
||||||
.HasColumnType("char(36)");
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CourseCategoryId")
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime(6)");
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
@@ -375,6 +378,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.HasIndex("Code")
|
b.HasIndex("Code")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("CourseCategoryId");
|
||||||
|
|
||||||
b.HasIndex("CollegeId", "Nature");
|
b.HasIndex("CollegeId", "Nature");
|
||||||
|
|
||||||
b.HasIndex("IsEnabled", "SortOrder");
|
b.HasIndex("IsEnabled", "SortOrder");
|
||||||
@@ -382,6 +387,44 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
b.ToTable("Courses");
|
b.ToTable("Courses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("char(36)");
|
||||||
|
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(40)
|
||||||
|
.HasColumnType("varchar(40)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("tinyint(1)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("varchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Code")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("IsEnabled", "SortOrder");
|
||||||
|
|
||||||
|
b.ToTable("CourseCategories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1948,7 +1991,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CourseCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
b.Navigation("College");
|
b.Navigation("College");
|
||||||
|
|
||||||
|
b.Navigation("CourseCategory");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
|
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
|
||||||
|
|||||||
@@ -5,6 +5,61 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class GraduationAuditRulesTests
|
public sealed class GraduationAuditRulesTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Curriculum_groups_Accept_Any_Options_That_Fulfill_Required_Credits()
|
||||||
|
{
|
||||||
|
var football = new Course { Code = "PE101", Name = "足球", Credits = 1 };
|
||||||
|
var basketball = new Course { Code = "PE102", Name = "篮球", Credits = 1 };
|
||||||
|
var swimming = new Course { Code = "PE103", Name = "游泳", Credits = 1 };
|
||||||
|
var module = new CurriculumModule
|
||||||
|
{
|
||||||
|
Code = "SPORTS",
|
||||||
|
Name = "大学体育",
|
||||||
|
RequiredCredits = 2,
|
||||||
|
Courses = [Option(football), Option(basketball), Option(swimming)]
|
||||||
|
};
|
||||||
|
|
||||||
|
var completed = CurriculumCompletionRules.Evaluate(
|
||||||
|
[module],
|
||||||
|
new HashSet<Guid>([football.Id, swimming.Id]));
|
||||||
|
var incomplete = CurriculumCompletionRules.Evaluate(
|
||||||
|
[module],
|
||||||
|
new HashSet<Guid>([football.Id]));
|
||||||
|
|
||||||
|
Assert.Equal(1, completed.RequirementCount);
|
||||||
|
Assert.Equal(1, completed.PassedRequirementCount);
|
||||||
|
Assert.Empty(completed.MissingRequirements);
|
||||||
|
Assert.Equal(0, incomplete.PassedRequirementCount);
|
||||||
|
Assert.Contains("大学体育(还差 1 学分)", incomplete.MissingRequirements);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Curriculum_groups_Still_Require_Every_Fixed_Course()
|
||||||
|
{
|
||||||
|
var ideology = new Course { Code = "MORAL101", Name = "思想道德与法治", Credits = 3 };
|
||||||
|
var module = new CurriculumModule
|
||||||
|
{
|
||||||
|
Code = "MORAL",
|
||||||
|
Name = "德育课程",
|
||||||
|
RequiredCredits = 3,
|
||||||
|
Courses =
|
||||||
|
[
|
||||||
|
new CurriculumCourse
|
||||||
|
{
|
||||||
|
CourseId = ideology.Id,
|
||||||
|
Course = ideology,
|
||||||
|
Type = CurriculumCourseType.Required
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = CurriculumCompletionRules.Evaluate([module], new HashSet<Guid>());
|
||||||
|
|
||||||
|
Assert.Equal(1, result.RequirementCount);
|
||||||
|
Assert.Equal(0, result.PassedRequirementCount);
|
||||||
|
Assert.Contains("思想道德与法治", result.MissingRequirements);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Active_student_with_full_credits_and_courses_is_eligible()
|
public void Active_student_with_full_credits_and_courses_is_eligible()
|
||||||
{
|
{
|
||||||
@@ -35,4 +90,12 @@ public sealed class GraduationAuditRulesTests
|
|||||||
|
|
||||||
Assert.Equal(GraduationAuditConclusion.Ineligible, conclusion);
|
Assert.Equal(GraduationAuditConclusion.Ineligible, conclusion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static CurriculumCourse Option(Course course) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
CourseId = course.Id,
|
||||||
|
Course = course,
|
||||||
|
Type = CurriculumCourseType.Elective
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
Nature = CourseNature.MajorRequired,
|
Nature = CourseNature.MajorRequired,
|
||||||
AssessmentMethod = AssessmentMethod.Examination
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
};
|
};
|
||||||
|
var category = new CourseCategory
|
||||||
|
{
|
||||||
|
Code = "BASIC",
|
||||||
|
Name = "基础课程"
|
||||||
|
};
|
||||||
|
course.CourseCategoryId = category.Id;
|
||||||
var teacher = new Teacher
|
var teacher = new Teacher
|
||||||
{
|
{
|
||||||
TeacherNumber = "T2026001",
|
TeacherNumber = "T2026001",
|
||||||
@@ -148,6 +154,7 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
counselor,
|
counselor,
|
||||||
administrativeClass,
|
administrativeClass,
|
||||||
teacher,
|
teacher,
|
||||||
|
category,
|
||||||
new Student
|
new Student
|
||||||
{
|
{
|
||||||
StudentNumber = "202601001",
|
StudentNumber = "202601001",
|
||||||
@@ -237,7 +244,10 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
var savedClass = await _db.AdministrativeClasses
|
var savedClass = await _db.AdministrativeClasses
|
||||||
.Include(x => x.CounselorUser)
|
.Include(x => x.CounselorUser)
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
var savedCourse = await _db.Courses.Include(x => x.College).SingleAsync();
|
var savedCourse = await _db.Courses
|
||||||
|
.Include(x => x.College)
|
||||||
|
.Include(x => x.CourseCategory)
|
||||||
|
.SingleAsync();
|
||||||
var curriculumPlan = await _db.CurriculumPlans
|
var curriculumPlan = await _db.CurriculumPlans
|
||||||
.Include(x => x.Modules)
|
.Include(x => x.Modules)
|
||||||
.ThenInclude(x => x.Courses)
|
.ThenInclude(x => x.Courses)
|
||||||
@@ -255,6 +265,7 @@ public sealed class PersistenceTests : IAsyncLifetime
|
|||||||
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name);
|
Assert.Equal("计算机科学与技术", student.AdministrativeClass!.Major!.Name);
|
||||||
Assert.Equal("陈老师", savedClass.CounselorUser!.DisplayName);
|
Assert.Equal("陈老师", savedClass.CounselorUser!.DisplayName);
|
||||||
Assert.Equal(4m, savedCourse.Credits);
|
Assert.Equal(4m, savedCourse.Credits);
|
||||||
|
Assert.Equal("基础课程", savedCourse.CourseCategory!.Name);
|
||||||
Assert.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours);
|
Assert.Equal(savedCourse.TotalHours, savedCourse.LectureHours + savedCourse.PracticeHours);
|
||||||
Assert.Single(curriculumPlan.Modules);
|
Assert.Single(curriculumPlan.Modules);
|
||||||
Assert.Single(curriculumPlan.Modules.Single().Courses);
|
Assert.Single(curriculumPlan.Modules.Single().Courses);
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
key: 'teaching-construction',
|
key: 'teaching-construction',
|
||||||
label: '教学建设',
|
label: '教学建设',
|
||||||
items: [
|
items: [
|
||||||
|
...whenVisible(
|
||||||
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||||
|
{ path: '/base-data/course-categories', label: '课程分类' },
|
||||||
|
),
|
||||||
{ path: '/courses', label: '课程库' },
|
{ path: '/courses', label: '课程库' },
|
||||||
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
|
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
|
||||||
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
|
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ const router = createRouter({
|
|||||||
baseGroup: 'facilities',
|
baseGroup: 'facilities',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'base-data/course-categories',
|
||||||
|
name: 'course-category-data',
|
||||||
|
component: () => import('../views/BaseDataView.vue'),
|
||||||
|
meta: {
|
||||||
|
roles: ['SuperAdmin', 'AcademicAdmin'],
|
||||||
|
baseGroup: 'course-categories',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'personnel',
|
path: 'personnel',
|
||||||
redirect: '/teachers',
|
redirect: '/teachers',
|
||||||
|
|||||||
@@ -227,6 +227,14 @@ button { cursor: pointer; }
|
|||||||
.record-name, .course-name { display: grid; gap: 3px; }
|
.record-name, .course-name { display: grid; gap: 3px; }
|
||||||
.record-name b, .course-name b { color: var(--ink); font-size: 13px; }
|
.record-name b, .course-name b { color: var(--ink); font-size: 13px; }
|
||||||
.record-name span, .course-name span { color: #9299a7; font-size: 10px; }
|
.record-name span, .course-name span { color: #9299a7; font-size: 10px; }
|
||||||
|
.course-category { display: inline-flex; padding: 3px 8px; color: #335b75; background: #eaf2f5; border-radius: 12px; font-size: 10px; white-space: nowrap; }
|
||||||
|
.taxonomy-note { min-height: 56px; padding: 8px 12px; display: grid; align-content: center; gap: 4px; border-left: 3px solid var(--teal); background: #f4f8f8; }
|
||||||
|
.taxonomy-note b { color: var(--ink); font-size: 11px; }
|
||||||
|
.taxonomy-note span { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||||
|
.form-help { margin-left: 10px; color: var(--muted); font-size: 10px; }
|
||||||
|
.course-rule-help { margin-top: 4px; padding: 12px 14px; display: grid; grid-template-columns: 74px 1fr; gap: 7px 12px; background: #f5f8fa; border-left: 3px solid var(--teal); }
|
||||||
|
.course-rule-help b { color: var(--ink); font-size: 11px; }
|
||||||
|
.course-rule-help span { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||||
.pagination-bar { min-height: 62px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); }
|
.pagination-bar { min-height: 62px; padding: 12px 18px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); }
|
||||||
.pagination-bar > span { color: var(--muted); font-size: 11px; }
|
.pagination-bar > span { color: var(--muted); font-size: 11px; }
|
||||||
.course-ledger { min-height: 92px; display: flex; align-items: stretch; color: white; background: linear-gradient(105deg, #1a2d61, #263f80); overflow: hidden; position: relative; }
|
.course-ledger { min-height: 92px; display: flex; align-items: stretch; color: white; background: linear-gradient(105deg, #1a2d61, #263f80); overflow: hidden; position: relative; }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import http, { apiErrorMessage } from '../api/http'
|
|||||||
import { downloadApiFile, importExcel } from '../api/excel'
|
import { downloadApiFile, importExcel } from '../api/excel'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms'
|
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories'
|
||||||
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
|
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
|
||||||
|
|
||||||
const allTabs: { key: Kind; label: string; hint: string }[] = [
|
const allTabs: { key: Kind; label: string; hint: string }[] = [
|
||||||
@@ -17,6 +17,7 @@ const allTabs: { key: Kind; label: string; hint: string }[] = [
|
|||||||
{ key: 'terms', label: '学期', hint: '教学运行时间轴' },
|
{ key: 'terms', label: '学期', hint: '教学运行时间轴' },
|
||||||
{ key: 'buildings', label: '教学楼', hint: '校区内教学建筑' },
|
{ key: 'buildings', label: '教学楼', hint: '校区内教学建筑' },
|
||||||
{ key: 'classrooms', label: '教室', hint: '可排课教学空间' },
|
{ key: 'classrooms', label: '教室', hint: '可排课教学空间' },
|
||||||
|
{ key: 'course-categories', label: '课程分类', hint: '课程所属教学领域' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -40,6 +41,12 @@ const groups = {
|
|||||||
description: '按校区和教学楼维护可用于排课的教室资源。',
|
description: '按校区和教学楼维护可用于排课的教室资源。',
|
||||||
kinds: ['buildings', 'classrooms'] as Kind[],
|
kinds: ['buildings', 'classrooms'] as Kind[],
|
||||||
},
|
},
|
||||||
|
'course-categories': {
|
||||||
|
title: '课程分类',
|
||||||
|
kicker: 'COURSE TAXONOMY',
|
||||||
|
description: '统一维护基础课、美育、德育、创新创业等课程教学领域。课程性质与课程分类相互独立。',
|
||||||
|
kinds: ['course-categories'] as Kind[],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const group = computed(() => {
|
const group = computed(() => {
|
||||||
const key = String(route.meta.baseGroup ?? 'organization') as keyof typeof groups
|
const key = String(route.meta.baseGroup ?? 'organization') as keyof typeof groups
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
import { Document, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
|
import { downloadApiFile, importExcel } from '../api/excel'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -9,13 +10,17 @@ const loading = ref(false)
|
|||||||
const rows = ref<any[]>([])
|
const rows = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const colleges = ref<any[]>([])
|
const colleges = ref<any[]>([])
|
||||||
|
const categories = ref<any[]>([])
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editingId = ref('')
|
const editingId = ref('')
|
||||||
|
const importing = ref(false)
|
||||||
|
const importInput = ref<HTMLInputElement>()
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
collegeId: undefined as string | undefined,
|
collegeId: undefined as string | undefined,
|
||||||
|
categoryId: undefined as string | undefined,
|
||||||
nature: undefined as string | undefined,
|
nature: undefined as string | undefined,
|
||||||
isEnabled: undefined as boolean | undefined,
|
isEnabled: undefined as boolean | undefined,
|
||||||
})
|
})
|
||||||
@@ -68,6 +73,7 @@ function resetForm(row?: any) {
|
|||||||
name: '',
|
name: '',
|
||||||
englishName: '',
|
englishName: '',
|
||||||
collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined,
|
collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined,
|
||||||
|
courseCategoryId: categories.value.find((item) => item.isEnabled)?.id,
|
||||||
credits: 2,
|
credits: 2,
|
||||||
totalHours: 32,
|
totalHours: 32,
|
||||||
lectureHours: 24,
|
lectureHours: 24,
|
||||||
@@ -89,6 +95,7 @@ async function load() {
|
|||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
keyword: query.keyword || undefined,
|
keyword: query.keyword || undefined,
|
||||||
collegeId: query.collegeId,
|
collegeId: query.collegeId,
|
||||||
|
categoryId: query.categoryId,
|
||||||
nature: query.nature,
|
nature: query.nature,
|
||||||
isEnabled: query.isEnabled,
|
isEnabled: query.isEnabled,
|
||||||
},
|
},
|
||||||
@@ -107,12 +114,67 @@ async function resetFilters() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
collegeId: undefined,
|
collegeId: undefined,
|
||||||
|
categoryId: undefined,
|
||||||
nature: undefined,
|
nature: undefined,
|
||||||
isEnabled: undefined,
|
isEnabled: undefined,
|
||||||
})
|
})
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportParams() {
|
||||||
|
return {
|
||||||
|
keyword: query.keyword || undefined,
|
||||||
|
collegeId: query.collegeId,
|
||||||
|
categoryId: query.categoryId,
|
||||||
|
nature: query.nature,
|
||||||
|
isEnabled: query.isEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadTemplate() {
|
||||||
|
try {
|
||||||
|
await downloadApiFile('/courses/template', '课程库导入模板.xlsx')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportRows() {
|
||||||
|
try {
|
||||||
|
await downloadApiFile(
|
||||||
|
'/courses/export',
|
||||||
|
'课程库.xlsx',
|
||||||
|
{ params: exportParams() },
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseImportFile() {
|
||||||
|
importInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await importExcel('/courses/import', file)
|
||||||
|
ElMessage.success(
|
||||||
|
`导入完成:新增 ${data.created} 门,更新 ${data.updated} 门,共处理 ${data.total} 门。`,
|
||||||
|
)
|
||||||
|
query.page = 1
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = ''
|
editingId.value = ''
|
||||||
resetForm()
|
resetForm()
|
||||||
@@ -126,8 +188,9 @@ function openEdit(row: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.code?.trim() || !form.name?.trim() || !form.collegeId) {
|
if (!form.code?.trim() || !form.name?.trim() ||
|
||||||
ElMessage.warning('请填写课程编码、名称和开课学院。')
|
!form.collegeId || !form.courseCategoryId) {
|
||||||
|
ElMessage.warning('请填写课程编码、名称、开课学院和课程分类。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.lectureHours + form.practiceHours > form.totalHours) {
|
if (form.lectureHours + form.practiceHours > form.totalHours) {
|
||||||
@@ -161,7 +224,12 @@ async function remove(row: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
colleges.value = (await http.get('/base-data/colleges')).data
|
const [collegeRes, categoryRes] = await Promise.all([
|
||||||
|
http.get('/base-data/colleges'),
|
||||||
|
http.get('/base-data/course-categories'),
|
||||||
|
])
|
||||||
|
colleges.value = collegeRes.data
|
||||||
|
categories.value = categoryRes.data
|
||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -174,7 +242,10 @@ onMounted(async () => {
|
|||||||
<h2>课程库</h2>
|
<h2>课程库</h2>
|
||||||
<p>通识课程由校级统筹,专业课与实践课由开课学院维护,统一供培养方案与教学任务引用。</p>
|
<p>通识课程由校级统筹,专业课与实践课由开课学院维护,统一供培养方案与教学任务引用。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
|
<div class="page-actions">
|
||||||
|
<el-button :icon="Download" @click="exportRows">导出结果</el-button>
|
||||||
|
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="course-ledger">
|
<section class="course-ledger">
|
||||||
@@ -202,6 +273,9 @@ onMounted(async () => {
|
|||||||
<el-select v-model="query.collegeId" clearable placeholder="全部开课学院">
|
<el-select v-model="query.collegeId" clearable placeholder="全部开课学院">
|
||||||
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-select v-model="query.categoryId" clearable placeholder="全部课程分类">
|
||||||
|
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
<el-select v-model="query.nature" clearable placeholder="全部课程性质">
|
<el-select v-model="query.nature" clearable placeholder="全部课程性质">
|
||||||
<el-option v-for="(label, value) in natureLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in natureLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -212,6 +286,20 @@ onMounted(async () => {
|
|||||||
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
|
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
|
||||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<template v-if="canManage">
|
||||||
|
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||||
|
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||||
|
<input
|
||||||
|
ref="importInput"
|
||||||
|
class="visually-hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx"
|
||||||
|
@change="handleImport"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span>当前筛选条件会原样用于 Excel 导出 · 共 {{ total }} 门</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" :data="rows" class="data-table course-table">
|
<el-table v-loading="loading" :data="rows" class="data-table course-table">
|
||||||
<el-table-column label="课程编码" width="130">
|
<el-table-column label="课程编码" width="130">
|
||||||
@@ -226,6 +314,11 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="collegeName" label="开课学院" min-width="145" />
|
<el-table-column prop="collegeName" label="开课学院" min-width="145" />
|
||||||
|
<el-table-column label="课程分类" width="105">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="course-category">{{ row.categoryName || '未分类' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="性质" width="105">
|
<el-table-column label="性质" width="105">
|
||||||
<template #default="{ row }">{{ natureLabels[row.nature] }}</template>
|
<template #default="{ row }">{{ natureLabels[row.nature] }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -279,11 +372,27 @@ onMounted(async () => {
|
|||||||
<el-option v-for="item in formColleges" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in formColleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="课程分类" required>
|
||||||
|
<el-select v-model="form.courseCategoryId" filterable>
|
||||||
|
<el-option
|
||||||
|
v-for="item in categories.filter((category) => category.isEnabled)"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
<el-form-item label="课程性质">
|
<el-form-item label="课程性质">
|
||||||
<el-select v-model="form.nature">
|
<el-select v-model="form.nature">
|
||||||
<el-option v-for="(label, value) in collegeNatureLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in collegeNatureLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="taxonomy-note">
|
||||||
|
<b>两个维度</b>
|
||||||
|
<span>分类表示教学领域,性质决定课程在培养方案中的管理方式。</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid three">
|
<div class="form-grid three">
|
||||||
<el-form-item label="学分"><el-input-number v-model="form.credits" :min="0.1" :max="99" :step="0.5" /></el-form-item>
|
<el-form-item label="学分"><el-input-number v-model="form.credits" :min="0.1" :max="99" :step="0.5" /></el-form-item>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ const statusLabels: Record<string, string> = {
|
|||||||
Archived: '已归档',
|
Archived: '已归档',
|
||||||
}
|
}
|
||||||
const courseTypeLabels: Record<string, string> = {
|
const courseTypeLabels: Record<string, string> = {
|
||||||
Required: '必修',
|
Required: '指定必修',
|
||||||
Elective: '选修',
|
Elective: '组内选修',
|
||||||
}
|
}
|
||||||
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
|
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
|
||||||
const availableMajors = computed(() => majors.value)
|
const availableMajors = computed(() => majors.value)
|
||||||
@@ -410,7 +410,7 @@ onMounted(async () => {
|
|||||||
<div class="module-toolbar">
|
<div class="module-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<b>课程结构</b>
|
<b>课程结构</b>
|
||||||
<span>发布前模块学分合计须与方案总学分一致</span>
|
<span>指定必修须逐门通过;英语、体育等多选课程用“组内选修”,修满模块最低学分即可</span>
|
||||||
</div>
|
</div>
|
||||||
<el-button v-if="isDraft" :icon="Plus" @click="openModule()">新增模块</el-button>
|
<el-button v-if="isDraft" :icon="Plus" @click="openModule()">新增模块</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,7 +421,12 @@ onMounted(async () => {
|
|||||||
<span>{{ module.code }}</span>
|
<span>{{ module.code }}</span>
|
||||||
<h4>{{ module.name }}</h4>
|
<h4>{{ module.name }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<p>最低 {{ module.requiredCredits }} 学分 · 已配置 {{ module.assignedCredits }} 学分</p>
|
<p>
|
||||||
|
最低 {{ module.requiredCredits }} 学分 · 已配置 {{ module.assignedCredits }} 学分
|
||||||
|
<template v-if="module.courses.some((course: any) => course.type === 'Elective')">
|
||||||
|
· 课程组选项
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
<div v-if="isDraft">
|
<div v-if="isDraft">
|
||||||
<el-button link type="primary" @click="openModule(module)">编辑</el-button>
|
<el-button link type="primary" @click="openModule(module)">编辑</el-button>
|
||||||
<el-button link type="danger" @click="deleteModule(module)">删除</el-button>
|
<el-button link type="danger" @click="deleteModule(module)">删除</el-button>
|
||||||
@@ -436,6 +441,9 @@ onMounted(async () => {
|
|||||||
<el-table-column label="类别" width="80">
|
<el-table-column label="类别" width="80">
|
||||||
<template #default="{ row }">{{ courseTypeLabels[row.type] }}</template>
|
<template #default="{ row }">{{ courseTypeLabels[row.type] }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="课程分类" width="100">
|
||||||
|
<template #default="{ row }">{{ row.categoryName || '未分类' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="credits" label="学分" width="70" />
|
<el-table-column prop="credits" label="学分" width="70" />
|
||||||
<el-table-column prop="totalHours" label="学时" width="70" />
|
<el-table-column prop="totalHours" label="学时" width="70" />
|
||||||
<el-table-column label="建议学期" width="90">
|
<el-table-column label="建议学期" width="90">
|
||||||
@@ -489,7 +497,10 @@ onMounted(async () => {
|
|||||||
<el-form-item label="排序"><el-input-number v-model="moduleForm.sortOrder" :min="0" /></el-form-item>
|
<el-form-item label="排序"><el-input-number v-model="moduleForm.sortOrder" :min="0" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="模块名称" required><el-input v-model="moduleForm.name" /></el-form-item>
|
<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-item label="最低学分要求">
|
||||||
|
<el-input-number v-model="moduleForm.requiredCredits" :min="0" :precision="1" :step="0.5" />
|
||||||
|
<span class="form-help">体育、英语等课程组填写学生必须从组内修满的学分。</span>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="moduleDialog = false">取消</el-button><el-button type="primary" @click="saveModule">保存</el-button></template>
|
<template #footer><el-button @click="moduleDialog = false">取消</el-button><el-button type="primary" @click="saveModule">保存</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -498,14 +509,18 @@ onMounted(async () => {
|
|||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="课程" required>
|
<el-form-item label="课程" required>
|
||||||
<el-select v-model="courseForm.courseId" filterable>
|
<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-option v-for="item in courses" :key="item.id" :label="`${item.code} · ${item.name}${item.categoryName ? ` · ${item.categoryName}` : ''}`" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-grid">
|
<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-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>
|
<el-form-item label="建议学期"><el-input-number v-model="courseForm.recommendedSemester" :min="1" :max="selected?.schoolingYears * 2" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="备注"><el-input v-model="courseForm.notes" type="textarea" :rows="2" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="courseForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
<div class="course-rule-help">
|
||||||
|
<b>指定必修</b><span>该门课程必须通过</span>
|
||||||
|
<b>组内选修</b><span>足球、篮球、英语分层等作为选项,按模块最低学分验收</span>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
|
<template #footer><el-button @click="courseDialog = false">取消</el-button><el-button type="primary" @click="saveCourse">保存</el-button></template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
<div class="certificate-metrics">
|
<div class="certificate-metrics">
|
||||||
<div><span>学分完成</span><b>{{ myResult.earnedCredits }} / {{ myResult.requiredCredits }}</b><el-progress :percentage="percent(myResult.earnedCredits, myResult.requiredCredits)" :show-text="false" /></div>
|
<div><span>学分完成</span><b>{{ myResult.earnedCredits }} / {{ myResult.requiredCredits }}</b><el-progress :percentage="percent(myResult.earnedCredits, myResult.requiredCredits)" :show-text="false" /></div>
|
||||||
<div><span>必修课程</span><b>{{ myResult.passedRequiredCourseCount }} / {{ myResult.requiredCourseCount }}</b><p>已通过 / 应通过</p></div>
|
<div><span>必修要求</span><b>{{ myResult.passedRequiredCourseCount }} / {{ myResult.requiredCourseCount }}</b><p>已完成 / 应完成</p></div>
|
||||||
<div><span>未解决不及格</span><b>{{ myResult.failedCourseCount }}</b><p>门课程</p></div>
|
<div><span>未解决不及格</span><b>{{ myResult.failedCourseCount }}</b><p>门课程</p></div>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
@@ -206,7 +206,7 @@ onMounted(load)
|
|||||||
<article v-for="row in filteredResults" :key="row.id">
|
<article v-for="row in filteredResults" :key="row.id">
|
||||||
<div class="graduation-student"><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }} · {{ row.planName || '未匹配培养方案' }}</p></div>
|
<div class="graduation-student"><span>{{ row.studentNumber }} · {{ row.className }}</span><h4>{{ row.name }}</h4><p>{{ row.majorName }} · {{ row.planName || '未匹配培养方案' }}</p></div>
|
||||||
<div class="credit-progress"><span>学分完成度</span><b>{{ row.earnedCredits }} / {{ row.requiredCredits }}</b><el-progress :percentage="percent(row.earnedCredits, row.requiredCredits)" :show-text="false" /></div>
|
<div class="credit-progress"><span>学分完成度</span><b>{{ row.earnedCredits }} / {{ row.requiredCredits }}</b><el-progress :percentage="percent(row.earnedCredits, row.requiredCredits)" :show-text="false" /></div>
|
||||||
<div class="course-clearance"><span>必修通过</span><b>{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}</b><small v-if="row.missingCourseNames">缺:{{ row.missingCourseNames }}</small><small v-else>必修项目已完成</small></div>
|
<div class="course-clearance"><span>必修要求</span><b>{{ row.passedRequiredCourseCount }} / {{ row.requiredCourseCount }}</b><small v-if="row.missingCourseNames">缺:{{ row.missingCourseNames }}</small><small v-else>指定必修与课程组均已完成</small></div>
|
||||||
<div class="graduation-conclusion" :class="{ eligible: row.conclusion === 'Eligible' }"><el-icon><Check /></el-icon><span>{{ conclusionLabels[row.conclusion] }}</span><small v-if="row.isOverridden">人工复核</small><small v-else>规则计算</small></div>
|
<div class="graduation-conclusion" :class="{ eligible: row.conclusion === 'Eligible' }"><el-icon><Check /></el-icon><span>{{ conclusionLabels[row.conclusion] }}</span><small v-if="row.isOverridden">人工复核</small><small v-else>规则计算</small></div>
|
||||||
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openDecision(row)">人工复核</el-button>
|
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openDecision(row)">人工复核</el-button>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
Reference in New Issue
Block a user