课程库 Excel 导入导出已完成:
支持下载标准导入模板。 导出严格沿用当前关键词、学院、分类、课程性质和状态筛选。 以课程编码为唯一键:存在则更新,不存在则新增。 校验学院编码、课程分类、课程性质、学分学时及考核方式。 整批原子导入,任一行错误则全部不写入,并提示具体行号。 学院管理员仍只能维护本学院的专业课和实践课,无法借 Excel 越权。 界面入口沿用现有课程库工具栏样式。
This commit is contained in:
@@ -120,6 +120,44 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
})
|
||||
.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")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<Major>> CreateMajor(
|
||||
@@ -403,6 +441,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
"terms" => await db.AcademicTerms.FindAsync([id], cancellationToken),
|
||||
"buildings" => await db.Buildings.FindAsync([id], cancellationToken),
|
||||
"classrooms" => await db.Classrooms.FindAsync([id], cancellationToken),
|
||||
"course-categories" => await db.CourseCategories.FindAsync([id], cancellationToken),
|
||||
_ => null
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
["classes"] = ["编码", "名称", "所属专业编码", "年级", "辅导员工号", "排序", "状态"],
|
||||
["terms"] = ["编码", "名称", "学年", "学期季", "开始日期", "结束日期", "当前学期", "状态"],
|
||||
["buildings"] = ["编码", "名称", "所属校区编码", "排序", "状态"],
|
||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"]
|
||||
["classrooms"] = ["编码", "名称", "所属教学楼编码", "容量", "教室类型", "设备", "排序", "状态"],
|
||||
["course-categories"] = ["编码", "名称", "排序", "状态"]
|
||||
};
|
||||
|
||||
[HttpGet("{kind}/template")]
|
||||
@@ -90,6 +91,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
|
||||
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
|
||||
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
|
||||
"course-categories" => await ImportCourseCategoriesAsync(
|
||||
rows, errors, cancellationToken),
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
|
||||
@@ -147,6 +150,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
.OrderBy(x => x.Code).ToListAsync(cancellationToken))
|
||||
.Select(x => Row(x.Code, x.Name, x.Building!.Code, x.Capacity, x.RoomType,
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
foreach (var error in errors.Take(50))
|
||||
@@ -582,6 +619,7 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
"terms" => "学期",
|
||||
"buildings" => "教学楼",
|
||||
"classrooms" => "教室",
|
||||
"course-categories" => "课程分类",
|
||||
_ => "基础数据"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class CoursesController(
|
||||
int pageSize = 20,
|
||||
string? keyword = null,
|
||||
Guid? collegeId = null,
|
||||
Guid? categoryId = null,
|
||||
CourseNature? nature = null,
|
||||
bool? isEnabled = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -42,6 +43,8 @@ public sealed class CoursesController(
|
||||
var source = ScopedCourses().AsNoTracking();
|
||||
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)
|
||||
@@ -69,6 +72,9 @@ public sealed class CoursesController(
|
||||
x.EnglishName,
|
||||
x.CollegeId,
|
||||
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.TotalHours,
|
||||
x.LectureHours,
|
||||
@@ -106,6 +112,7 @@ public sealed class CoursesController(
|
||||
Name = request.Name.Trim(),
|
||||
EnglishName = Normalize(request.EnglishName),
|
||||
CollegeId = request.CollegeId,
|
||||
CourseCategoryId = request.CourseCategoryId,
|
||||
Credits = request.Credits,
|
||||
TotalHours = request.TotalHours,
|
||||
LectureHours = request.LectureHours,
|
||||
@@ -137,6 +144,7 @@ public sealed class CoursesController(
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.EnglishName = Normalize(request.EnglishName);
|
||||
entity.CollegeId = request.CollegeId;
|
||||
entity.CourseCategoryId = request.CourseCategoryId;
|
||||
entity.Credits = request.Credits;
|
||||
entity.TotalHours = request.TotalHours;
|
||||
entity.LectureHours = request.LectureHours;
|
||||
@@ -167,6 +175,10 @@ public sealed class CoursesController(
|
||||
if (!CanManage(request.CollegeId, request.Nature)) return Forbid();
|
||||
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
|
||||
return ValidationProblem("所选学院不存在。");
|
||||
if (!await db.CourseCategories.AnyAsync(
|
||||
x => x.Id == request.CourseCategoryId && x.IsEnabled,
|
||||
cancellationToken))
|
||||
return ValidationProblem("所选课程分类不存在或已停用。");
|
||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
|
||||
return null;
|
||||
@@ -242,6 +254,7 @@ public sealed record CourseRequest(
|
||||
[Required, MaxLength(100)] string Name,
|
||||
[MaxLength(150)] string? EnglishName,
|
||||
Guid CollegeId,
|
||||
Guid CourseCategoryId,
|
||||
[Range(typeof(decimal), "0.1", "99")] decimal Credits,
|
||||
[Range(1, 1000)] int TotalHours,
|
||||
[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.TotalHours,
|
||||
course.Course.Nature,
|
||||
CategoryName = course.Course.CourseCategory != null
|
||||
? course.Course.CourseCategory.Name
|
||||
: null,
|
||||
course.RecommendedSemester,
|
||||
course.Type,
|
||||
course.Notes
|
||||
|
||||
@@ -156,10 +156,6 @@ public sealed class GraduationAuditsController(
|
||||
{
|
||||
var plan = plans.FirstOrDefault(x =>
|
||||
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 passedCourseIds = studentGrades
|
||||
.Where(IsPassed)
|
||||
@@ -170,14 +166,12 @@ public sealed class GraduationAuditsController(
|
||||
.Where(IsPassed)
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Sum(x => x.Max(item => item.Credits));
|
||||
var missingCourses = requiredCourses
|
||||
.Where(x => !passedCourseIds.Contains(x.CourseId))
|
||||
.Select(x => x.Course!.Name)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var completion = CurriculumCompletionRules.Evaluate(
|
||||
plan?.Modules ?? [],
|
||||
passedCourseIds);
|
||||
var missingCourseNames = plan is null
|
||||
? "未匹配已发布的培养方案"
|
||||
: string.Join("、", missingCourses);
|
||||
: string.Join("、", completion.MissingRequirements);
|
||||
if (missingCourseNames.Length > 2000)
|
||||
missingCourseNames = missingCourseNames[..2000];
|
||||
var failedCourseCount = studentGrades
|
||||
@@ -188,8 +182,8 @@ public sealed class GraduationAuditsController(
|
||||
student.Status,
|
||||
plan?.TotalCredits ?? 0,
|
||||
earnedCredits,
|
||||
requiredCourses.Count,
|
||||
requiredCourses.Count - missingCourses.Length,
|
||||
completion.RequirementCount,
|
||||
completion.PassedRequirementCount,
|
||||
failedCourseCount);
|
||||
db.GraduationAuditResults.Add(new GraduationAuditResult
|
||||
{
|
||||
@@ -199,8 +193,8 @@ public sealed class GraduationAuditsController(
|
||||
StudentStatusSnapshot = student.Status,
|
||||
RequiredCredits = plan?.TotalCredits ?? 0,
|
||||
EarnedCredits = earnedCredits,
|
||||
RequiredCourseCount = requiredCourses.Count,
|
||||
PassedRequiredCourseCount = requiredCourses.Count - missingCourses.Length,
|
||||
RequiredCourseCount = completion.RequirementCount,
|
||||
PassedRequiredCourseCount = completion.PassedRequirementCount,
|
||||
FailedCourseCount = failedCourseCount,
|
||||
MissingCourseNames = missingCourseNames,
|
||||
CalculatedConclusion = conclusion,
|
||||
|
||||
Reference in New Issue
Block a user