From fdca6a3edfd2f69d4b90df484623aa9bab459c21 Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 25 Jul 2026 11:46:39 +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/CourseSelectionsController.cs | 5 +- .../Controllers/TeachingTasksController.cs | 41 ++ .../CourseSelection/CourseSelectionRules.cs | 3 + .../CourseSelectionRulesTests.cs | 12 + .../TeachingTasksControllerTests.cs | 97 ++++- web/src/components.d.ts | 1 + web/src/style.css | 110 +++++- web/src/views/CourseSelectionView.vue | 359 +++++++++++++++++- 8 files changed, 603 insertions(+), 25 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs index 7c5d59d..01e3a0c 100644 --- a/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs +++ b/src/Jiaowu.Api/Controllers/CourseSelectionsController.cs @@ -499,7 +499,8 @@ public sealed class CourseSelectionsController( round.AcademicTermId, [task.Id], cancellationToken); - if (candidateEntries.Count == 0) + if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && + candidateEntries.Count == 0) return ConflictProblem("该教学班尚未发布课表,暂时不能办理代选。"); foreach (var student in students) @@ -804,7 +805,7 @@ public sealed class CourseSelectionsController( round.AcademicTermId, [task.Id], cancellationToken); - if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard && + if (CourseSelectionRules.RequiresPublishedSchedule(task.SchedulingMode) && candidateEntries.Count == 0) return ConflictProblem("该教学班尚未发布课表,暂时不能选课。"); var selectedTaskIds = await db.CourseEnrollments diff --git a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs index 5f465ad..fa53ffc 100644 --- a/src/Jiaowu.Api/Controllers/TeachingTasksController.cs +++ b/src/Jiaowu.Api/Controllers/TeachingTasksController.cs @@ -95,6 +95,47 @@ public sealed class TeachingTasksController( return Ok(new PagedResult(items, total, page, pageSize)); } + [HttpGet("options")] + public async Task GetOptions( + Guid academicTermId, + TeachingTaskStatus status = TeachingTaskStatus.Published, + CancellationToken cancellationToken = default) + { + var items = await ScopedTasks() + .AsNoTracking() + .Where(x => + x.AcademicTermId == academicTermId && + x.Status == status) + .OrderBy(x => x.Course!.Code) + .ThenBy(x => x.TaskNumber) + .Select(x => new + { + x.Id, + x.TaskNumber, + x.Name, + x.AcademicTermId, + x.CourseId, + CourseCode = x.Course!.Code, + CourseName = x.Course.Name, + CourseNature = x.Course.Nature, + CollegeId = x.Course.CollegeId, + CollegeName = x.Course.College!.Name, + x.Capacity, + x.StartWeek, + x.EndWeek, + x.WeeklyHours, + x.SchedulingMode, + TeacherNames = x.Teachers + .OrderByDescending(item => item.IsPrimary) + .Select(item => item.Teacher!.Name), + ClassNames = x.Classes + .OrderBy(item => item.AdministrativeClass!.Code) + .Select(item => item.AdministrativeClass!.Name) + }) + .ToListAsync(cancellationToken); + return Ok(items); + } + [HttpGet("{id:guid}")] public async Task GetOne(Guid id, CancellationToken cancellationToken) { diff --git a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs index 10064eb..702e28c 100644 --- a/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs +++ b/src/Jiaowu.Api/Infrastructure/CourseSelection/CourseSelectionRules.cs @@ -17,6 +17,9 @@ public static class CourseSelectionRules public static bool SupportsProxyEnrollment(CourseNature nature) => nature == CourseNature.GeneralRequired; + public static bool RequiresPublishedSchedule(TeachingTaskSchedulingMode schedulingMode) => + schedulingMode == TeachingTaskSchedulingMode.Standard; + public static bool HasScheduleConflict( IEnumerable candidateEntries, IEnumerable selectedEntries) => diff --git a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs index 9c004da..a4f774c 100644 --- a/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs +++ b/tests/Jiaowu.Api.Tests/CourseSelectionRulesTests.cs @@ -66,6 +66,18 @@ public sealed class CourseSelectionRulesTests Assert.Equal(expected, CourseSelectionRules.SupportsProxyEnrollment(nature)); } + [Theory] + [InlineData(TeachingTaskSchedulingMode.Standard, true)] + [InlineData(TeachingTaskSchedulingMode.Flexible, false)] + public void Only_standard_courses_require_a_published_schedule( + TeachingTaskSchedulingMode schedulingMode, + bool expected) + { + Assert.Equal( + expected, + CourseSelectionRules.RequiresPublishedSchedule(schedulingMode)); + } + private static CourseSelectionRound CreateRound(DateTime startsAt, DateTime endsAt) => new() { diff --git a/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs b/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs index 465ac48..71538b5 100644 --- a/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/TeachingTasksControllerTests.cs @@ -65,6 +65,68 @@ public sealed class TeachingTasksControllerTests task => Assert.Equal(TeachingTaskStatus.Published, task.Status)); } + [Fact] + public async Task Options_ReturnsAllPublishedTasksWithoutListPageTruncation() + { + await using var database = await TestDatabase.CreateAsync(); + for (var index = 1; index <= 125; index++) + { + await database.AddTaskAsync( + $"OPTION-{index:D3}", + TeachingTaskStatus.Published); + } + await database.AddTaskAsync("DRAFT", TeachingTaskStatus.Draft); + + var result = await database.Controller.GetOptions( + database.TermId, + TeachingTaskStatus.Published, + CancellationToken.None); + + var ok = Assert.IsType(result); + var items = Assert.IsAssignableFrom(ok.Value); + Assert.Equal(125, items.Cast().Count()); + } + + [Fact] + public async Task Options_PreservesCollegeDataScope() + { + await using var database = await TestDatabase.CreateAsync(collegeScoped: true); + await database.AddTaskAsync("IN-SCOPE", TeachingTaskStatus.Published); + var otherCollege = new College { Code = "OTHER", Name = "其他学院" }; + var otherCourse = new Course + { + Code = "OTHER101", + Name = "范围外课程", + College = otherCollege, + Nature = CourseNature.MajorRequired, + TotalHours = 32, + LectureHours = 32 + }; + database.Db.Add(new TeachingTask + { + TaskNumber = "TASK-OUT-OF-SCOPE", + Name = "范围外教学任务", + AcademicTermId = database.TermId, + Course = otherCourse, + Capacity = 40, + StartWeek = 1, + EndWeek = 16, + WeeklyHours = 2, + Status = TeachingTaskStatus.Published, + PublishedAt = DateTime.UtcNow + }); + await database.Db.SaveChangesAsync(); + + var result = await database.Controller.GetOptions( + database.TermId, + TeachingTaskStatus.Published, + CancellationToken.None); + + var ok = Assert.IsType(result); + var items = Assert.IsAssignableFrom(ok.Value); + Assert.Single(items.Cast()); + } + private sealed class TestDatabase : IAsyncDisposable { private readonly SqliteConnection connection; @@ -75,7 +137,8 @@ public sealed class TeachingTasksControllerTests SqliteConnection connection, AppDbContext db, AcademicTerm term, - Course course) + Course course, + ICurrentUserDataScope dataScope) { this.connection = connection; Db = db; @@ -83,13 +146,14 @@ public sealed class TeachingTasksControllerTests this.course = course; Controller = new TeachingTasksController( db, - new TestDataScope()); + dataScope); } public AppDbContext Db { get; } public TeachingTasksController Controller { get; } + public Guid TermId => term.Id; - public static async Task CreateAsync() + public static async Task CreateAsync(bool collegeScoped = false) { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); @@ -120,7 +184,12 @@ public sealed class TeachingTasksControllerTests }; db.AddRange(college, term, course); await db.SaveChangesAsync(); - return new TestDatabase(connection, db, term, course); + return new TestDatabase( + connection, + db, + term, + course, + new TestDataScope(collegeScoped ? college.Id : null)); } public async Task AddTaskAsync( @@ -156,11 +225,19 @@ public sealed class TeachingTasksControllerTests private sealed class TestDataScope : ICurrentUserDataScope { - public CurrentUserScope Current { get; } = new( - Guid.NewGuid(), - "测试管理员", - null, - DataScope.All, - new HashSet([SystemRoles.SuperAdmin])); + public TestDataScope(Guid? restrictedCollegeId = null) + { + Current = new CurrentUserScope( + Guid.NewGuid(), + "测试管理员", + restrictedCollegeId, + restrictedCollegeId.HasValue ? DataScope.College : DataScope.All, + new HashSet( + restrictedCollegeId.HasValue + ? [SystemRoles.CollegeAdmin] + : [SystemRoles.SuperAdmin])); + } + + public CurrentUserScope Current { get; } } } diff --git a/web/src/components.d.ts b/web/src/components.d.ts index 29ea3cd..123e2f1 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -34,6 +34,7 @@ declare module 'vue' { ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElSegmented: typeof import('element-plus/es')['ElSegmented'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] diff --git a/web/src/style.css b/web/src/style.css index 2ddef95..18dafb7 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -482,11 +482,105 @@ button { cursor: pointer; } .selection-ledger-head h3 { margin: 7px 0 4px; font-family: "STZhongsong", "Songti SC", serif; font-size: 19px; } .selection-ledger-head p { margin: 0; color: var(--muted); font-size: 10px; } .capacity-number { color: var(--indigo); font: 700 13px/1 Consolas, monospace; } +.selection-timetable-card { min-width: 0; overflow: hidden; } +.selection-timetable-head, +.selection-catalog-head { + min-height: 92px; padding: 18px 21px; display: flex; align-items: center; + justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line); +} +.selection-timetable-head > div, +.selection-catalog-head > div { min-width: 0; } +.selection-timetable-head span, +.selection-catalog-head span { + color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .14em; +} +.selection-timetable-head h3, +.selection-catalog-head h3 { + margin: 7px 0 4px; font-family: "STZhongsong", "Songti SC", serif; font-size: 19px; +} +.selection-timetable-head p, +.selection-catalog-head p { margin: 0; color: var(--muted); font-size: 10px; } +.selection-preview-alert { margin: 14px 16px 0; width: auto; } +.selection-timetable-scroll { min-width: 0; overflow-x: auto; background: #f7f9fb; } +.selection-timetable-grid { + min-width: 970px; padding: 0 12px 14px; display: grid; + grid-template-columns: 62px repeat(7, minmax(120px, 1fr)); + position: relative; +} +.timetable-corner, +.timetable-day { + display: flex; align-items: center; justify-content: center; + border-bottom: 1px solid #cfd8e2; color: #566277; background: #edf2f6; + font-size: 10px; font-weight: 700; z-index: 3; +} +.timetable-corner { grid-column: 1; grid-row: 1; gap: 4px; color: var(--indigo); } +.timetable-day { border-left: 1px solid #d8e0e8; } +.timetable-period { + padding-right: 9px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; + border-bottom: 1px solid #dfe5eb; color: #657084; background: #f2f5f8; z-index: 1; +} +.timetable-period b { color: var(--indigo); font: 700 15px/1 Consolas, monospace; } +.timetable-period span { font-size: 8px; } +.timetable-cell { + border-left: 1px solid #e0e6ec; border-bottom: 1px solid #e0e6ec; + background: + linear-gradient(135deg, rgba(37,58,115,.018) 25%, transparent 25%) 0 0 / 8px 8px, + white; +} +.timetable-course { + min-width: 0; padding: 4px; display: flex; flex-direction: column; gap: 3px; + z-index: 2; pointer-events: none; +} +.timetable-course.multiple { flex-direction: row; } +.timetable-course article { + min-width: 0; min-height: 0; flex: 1; padding: 7px 8px; overflow: hidden; + border-left: 3px solid #566fc1; color: #283554; background: #e8edfb; + box-shadow: 0 2px 5px rgba(28,44,84,.08); +} +.timetable-course article.tone-1 { border-left-color: #2b988b; background: #e3f2ef; } +.timetable-course article.tone-2 { border-left-color: #c1812c; background: #f8eddc; } +.timetable-course article.tone-3 { border-left-color: #8a5aa8; background: #f0e8f4; } +.timetable-course article.tone-4 { border-left-color: #3982a3; background: #e4f0f5; } +.timetable-course article.preview { + outline: 2px dashed #d29135; outline-offset: -2px; background: #fff6df; +} +.timetable-course article.conflict { + border-left-color: #bd3e49; outline-color: #bd3e49; background: #fde8e9; +} +.timetable-course b, +.timetable-course span, +.timetable-course small { + display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; +} +.timetable-course b { font-size: 10px; line-height: 1.3; } +.timetable-course span { margin-top: 4px; color: #5e6880; font-size: 8px; } +.timetable-course small { margin-top: 3px; color: #697486; font-size: 8px; } +.flexible-course-row { + padding: 13px 18px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + border-top: 1px solid var(--line); background: white; +} +.flexible-course-row > b { margin-right: 4px; color: #4e596d; font-size: 10px; } +.flexible-course-row > span { + padding: 5px 8px; border: 1px solid #cad7df; color: #48606c; background: #f4f8f9; + font-size: 9px; +} +.flexible-course-row > span.preview { border-style: dashed; border-color: #d29135; background: #fff6df; } +.selection-catalog { overflow: hidden; } +.selection-catalog-head > b { + flex: 0 0 auto; color: var(--indigo); font: 700 18px/1 Consolas, monospace; +} +.selection-catalog-filter { + padding: 13px 16px; display: grid; grid-template-columns: minmax(260px, 1fr) auto; + align-items: center; gap: 12px; background: #f8fafb; +} +.selection-catalog-filter .el-segmented { --el-segmented-item-selected-bg-color: var(--indigo); } .offering-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; } .offering-ticket { min-width: 0; min-height: 250px; display: flex; flex-direction: column; border: 1px solid var(--line); background: white; box-shadow: 0 6px 18px rgba(24,36,68,.045); } +.offering-ticket.previewing { border-color: #d49a48; box-shadow: 0 0 0 2px rgba(212,154,72,.12); } +.offering-ticket.blocked:not(.selected) { background: #fbfcfd; } .offering-ticket > header { min-height: 99px; padding: 19px 20px; display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px dashed #d7dce4; position: relative; @@ -512,11 +606,15 @@ button { cursor: pointer; } .ticket-schedules > div { display: flex; align-items: flex-start; gap: 7px; color: #4e596d; font-size: 10px; line-height: 1.5; } .ticket-schedules .el-icon { flex: 0 0 auto; margin-top: 1px; color: var(--indigo); } .schedule-missing { color: #a36d22; font-size: 10px; } +.selection-block-reason { + margin: 0 20px 12px; padding: 8px 10px; border-left: 3px solid #bd3e49; + color: #90343d; background: #fbedee; font-size: 9px; line-height: 1.5; +} .offering-ticket > footer { margin-top: auto; min-height: 65px; padding: 13px 20px; display: flex; align-items: center; - gap: 18px; background: #fafbfc; border-top: 1px solid #edf0f4; + flex-wrap: wrap; gap: 10px; background: #fafbfc; border-top: 1px solid #edf0f4; } -.seat-meter { flex: 1; min-width: 0; } +.seat-meter { flex: 1; min-width: 120px; } .seat-meter > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 9px; } .seat-meter > div { height: 4px; overflow: hidden; background: #e2e6ec; } .seat-meter i { height: 100%; display: block; background: var(--teal); } @@ -1051,6 +1149,8 @@ button { cursor: pointer; } border-left: none; } .round-actions { justify-content: flex-start; } + .selection-catalog-filter { grid-template-columns: 1fr; } + .selection-catalog-filter .el-segmented { width: 100%; } .offering-grid { grid-template-columns: 1fr; } .grade-workspace { display: block; } .grade-sheet-list { max-height: 300px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); } @@ -1090,6 +1190,12 @@ button { cursor: pointer; } .window-copy em { display: block; margin: 7px 0 0; padding: 0; border: none; } .credit-meter, .round-actions { padding: 18px; } .selection-ledger-head { align-items: flex-start; flex-direction: column; } + .selection-timetable-head, + .selection-catalog-head { align-items: flex-start; flex-direction: column; } + .selection-timetable-head .el-button { width: 100%; } + .selection-timetable-grid { min-width: 820px; } + .selection-catalog-filter .el-segmented { overflow-x: auto; } + .flexible-course-row { align-items: flex-start; flex-direction: column; } .offering-ticket > footer { align-items: stretch; flex-direction: column; } .offering-ticket > footer .el-button { width: 100%; } .grade-toolbar { flex-wrap: wrap; } diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index 8896574..d01dd70 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -1,10 +1,12 @@