From ccdfe977ba79c23889712d7c6d07e6f6525f1127 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 14 Aug 2026 19:28:58 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=89=E8=AF=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/CoursesController.cs | 5 +- .../Controllers/CoursesExcelController.cs | 5 +- .../Controllers/CurriculumPlansController.cs | 30 +++++ .../CoursesControllerTests.cs | 113 ++++++++++++++++++ web/src/style.css | 4 + web/src/views/BaseDataView.vue | 2 +- web/src/views/CoursesView.vue | 24 +++- web/src/views/CurriculumView.vue | 2 +- web/src/views/PersonnelView.vue | 7 +- 9 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 tests/Jiaowu.Api.Tests/CoursesControllerTests.cs diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index 2d26b07..b7c35ba 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -323,10 +323,7 @@ public sealed class CoursesController( 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); + return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); } if (scope.Scope == DataScope.Class) { diff --git a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs index 84a946b..4a5ed8f 100644 --- a/src/Jiaowu.Api/Controllers/CoursesExcelController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesExcelController.cs @@ -290,10 +290,7 @@ public sealed class CoursesExcelController( 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); + return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); } if (scope.Scope == DataScope.Class) { diff --git a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs index 16081d1..3c6db89 100644 --- a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs +++ b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs @@ -125,6 +125,36 @@ public sealed class CurriculumPlansController( return Ok(new { Colleges = colleges, Majors = majorOptions, Grades = grades }); } + [HttpGet("course-options")] + public async Task GetCourseOptions(CancellationToken cancellationToken) + { + // A plan may deliberately reference enabled courses offered by another college. + // This endpoint is limited to curriculum-plan managers; it must not share the + // course-catalogue list scope used for ordinary catalogue browsing. + var courses = await db.Courses.AsNoTracking() + .Where(x => x.IsEnabled) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new + { + x.Id, + x.Code, + x.Name, + x.CollegeId, + CollegeName = x.College!.Name, + x.CourseCategoryId, + CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null, + x.Credits, + x.TotalHours, + x.LectureHours, + x.PracticeHours, + x.Nature, + x.AssessmentMethod + }) + .ToListAsync(cancellationToken); + return Ok(courses); + } + [HttpGet("{id:guid}")] public async Task GetPlan(Guid id, CancellationToken cancellationToken) { diff --git a/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs b/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs new file mode 100644 index 0000000..be195c7 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/CoursesControllerTests.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Jiaowu.Api.Contracts; +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Tests; + +public sealed class CoursesControllerTests +{ + [Fact] + public async Task CollegeAdmin_GetWithoutCollegeFilter_ReturnsOnlyOwnCollegeCourses() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var ownCollege = new College { Code = "OWN", Name = "本院" }; + var otherCollege = new College { Code = "OTHER", Name = "外院" }; + var category = new CourseCategory { Code = "CORE", Name = "核心课程" }; + db.AddRange( + ownCollege, + otherCollege, + category, + new Course + { + Code = "OWN-001", Name = "本院课程", CollegeId = ownCollege.Id, + CourseCategoryId = category.Id, Nature = CourseNature.MajorRequired, + Credits = 2, TotalHours = 32, IsEnabled = true + }, + new Course + { + Code = "OTHER-001", Name = "外院通识课程", CollegeId = otherCollege.Id, + CourseCategoryId = category.Id, Nature = CourseNature.GeneralRequired, + Credits = 2, TotalHours = 32, IsEnabled = true + }); + await db.SaveChangesAsync(); + + var scope = new TestDataScope(ownCollege.Id); + var controller = new CoursesController(db, scope, NoOpAppCache.Instance); + + var result = await controller.Get(cancellationToken: CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var page = Assert.IsType>(ok.Value); + Assert.Equal(1, page.Total); + var items = JsonSerializer.Serialize(page.Items); + Assert.Contains("OWN-001", items); + Assert.DoesNotContain("OTHER-001", items); + } + + [Fact] + public async Task CollegeAdmin_CurriculumCourseOptions_IncludeEnabledCoursesFromOtherColleges() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new AppDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var ownCollege = new College { Code = "OWN", Name = "本院" }; + var otherCollege = new College { Code = "OTHER", Name = "外院" }; + var category = new CourseCategory { Code = "CORE", Name = "核心课程" }; + db.AddRange( + ownCollege, + otherCollege, + category, + new Course + { + Code = "OWN-001", Name = "本院课程", CollegeId = ownCollege.Id, + CourseCategoryId = category.Id, Nature = CourseNature.MajorRequired, + Credits = 2, TotalHours = 32, IsEnabled = true + }, + new Course + { + Code = "OTHER-001", Name = "外院课程", CollegeId = otherCollege.Id, + CourseCategoryId = category.Id, Nature = CourseNature.MajorRequired, + Credits = 2, TotalHours = 32, IsEnabled = true + }); + await db.SaveChangesAsync(); + + var controller = new CurriculumPlansController(db, new TestDataScope(ownCollege.Id)); + + var result = await controller.GetCourseOptions(CancellationToken.None); + + var ok = Assert.IsType(result); + var courses = JsonSerializer.Serialize(ok.Value); + Assert.Contains("OWN-001", courses); + Assert.Contains("OTHER-001", courses); + } + + private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + Guid.NewGuid(), + "学院管理员", + collegeId, + DataScope.College, + new HashSet([SystemRoles.CollegeAdmin])); + } +} diff --git a/web/src/style.css b/web/src/style.css index 42162bf..19ebcf8 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -180,6 +180,8 @@ button { cursor: pointer; } .table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); } .table-toolbar .el-input { width: min(340px, 60vw); } .table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; } +.base-data-toolbar > .el-select { width: 170px; } +.base-data-toolbar > .el-input-number { width: 142px; } .data-table { min-height: 360px; } .el-select-dropdown__item.academic-term-option { @@ -1632,6 +1634,8 @@ body { .el-tag { font-weight: 600; } @media (max-width: 760px) { + .base-data-toolbar > .el-select { flex: 1 1 140px; width: auto; } + .base-data-toolbar > .el-input-number { flex: 1 1 140px; } .page-intro, .page-heading { min-height: 0; padding: 12px 0 14px 14px; } .page-intro::before, diff --git a/web/src/views/BaseDataView.vue b/web/src/views/BaseDataView.vue index f93f45f..901be31 100644 --- a/web/src/views/BaseDataView.vue +++ b/web/src/views/BaseDataView.vue @@ -417,7 +417,7 @@ watch( -
+