diff --git a/src/Jiaowu.Api/Controllers/CoursesController.cs b/src/Jiaowu.Api/Controllers/CoursesController.cs index 74c7245..c77adc1 100644 --- a/src/Jiaowu.Api/Controllers/CoursesController.cs +++ b/src/Jiaowu.Api/Controllers/CoursesController.cs @@ -34,6 +34,11 @@ public sealed class CoursesController( { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); + var scope = currentUserDataScope.Current; + var canManageAll = scope.IsInRole(SystemRoles.SuperAdmin) || + scope.IsInRole(SystemRoles.AcademicAdmin); + var canManageCollegeCourses = scope.IsInRole(SystemRoles.CollegeAdmin); + var managedCollegeId = scope.CollegeId; var source = ScopedCourses().AsNoTracking(); if (collegeId.HasValue) source = source.Where(x => x.CollegeId == collegeId.Value); @@ -73,7 +78,13 @@ public sealed class CoursesController( x.Description, x.IsEnabled, x.SortOrder, - x.CreatedAt + x.CreatedAt, + CanManage = canManageAll || + canManageCollegeCourses && + x.CollegeId == managedCollegeId && + (x.Nature == CourseNature.MajorRequired || + x.Nature == CourseNature.MajorElective || + x.Nature == CourseNature.Practice) }) .ToListAsync(cancellationToken); @@ -118,7 +129,7 @@ public sealed class CoursesController( { var entity = await db.Courses.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); - if (!CanAccessCollege(entity.CollegeId)) return Forbid(); + if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid(); var validation = await ValidateAsync(request, cancellationToken); if (validation is not null) return validation; @@ -144,7 +155,7 @@ public sealed class CoursesController( { var entity = await db.Courses.FindAsync([id], cancellationToken); if (entity is null) return NotFound(); - if (!CanAccessCollege(entity.CollegeId)) return Forbid(); + if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid(); db.Courses.Remove(entity); return await SaveAsync(id, false, cancellationToken); } @@ -153,7 +164,7 @@ public sealed class CoursesController( CourseRequest request, CancellationToken cancellationToken) { - if (!CanAccessCollege(request.CollegeId)) return Forbid(); + if (!CanManage(request.CollegeId, request.Nature)) return Forbid(); if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken)) return ValidationProblem("所选学院不存在。"); if (request.LectureHours + request.PracticeHours > request.TotalHours) @@ -167,7 +178,12 @@ public sealed class CoursesController( var source = db.Courses.AsQueryable(); if (scope.Scope == DataScope.All) return source; if (scope.Scope == DataScope.College) - return source.Where(x => x.CollegeId == scope.RestrictedCollegeId); + { + 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 => @@ -190,8 +206,11 @@ public sealed class CoursesController( student.UserId == userId)))); } - private bool CanAccessCollege(Guid collegeId) => - currentUserDataScope.Current.CanAccessCollege(collegeId); + private bool CanManage(Guid collegeId, CourseNature nature) + => CourseMaintenancePolicy.CanManage( + currentUserDataScope.Current, + collegeId, + nature); private async Task SaveAsync( Guid id, diff --git a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs index f448370..530a899 100644 --- a/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs +++ b/src/Jiaowu.Api/Controllers/CurriculumPlansController.cs @@ -27,6 +27,7 @@ public sealed class CurriculumPlansController( int page = 1, int pageSize = 20, string? keyword = null, + Guid? collegeId = null, Guid? majorId = null, int? effectiveGrade = null, CurriculumPlanStatus? status = null, @@ -35,6 +36,8 @@ public sealed class CurriculumPlansController( page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 10, 100); var source = ScopedPlans().AsNoTracking(); + if (collegeId.HasValue) + source = source.Where(x => x.Major!.CollegeId == collegeId.Value); if (majorId.HasValue) source = source.Where(x => x.MajorId == majorId); if (effectiveGrade.HasValue) source = source.Where(x => x.EffectiveGrade == effectiveGrade); @@ -76,6 +79,49 @@ public sealed class CurriculumPlansController( return Ok(new PagedResult(items, total, page, pageSize)); } + [HttpGet("filter-options")] + public async Task GetFilterOptions(CancellationToken cancellationToken) + { + var collegeId = ScopedCollegeId(); + var majors = db.Majors.AsNoTracking().Where(x => x.IsEnabled); + if (collegeId.HasValue) + majors = majors.Where(x => x.CollegeId == collegeId.Value); + + var majorOptions = await majors + .OrderBy(x => x.College!.SortOrder) + .ThenBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new + { + x.Id, + x.Code, + x.Name, + x.CollegeId, + CollegeName = x.College!.Name, + x.SchoolingYears + }) + .ToListAsync(cancellationToken); + + var accessibleCollegeIds = majorOptions + .Select(x => x.CollegeId) + .Distinct() + .ToArray(); + var colleges = await db.Colleges.AsNoTracking() + .Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id)) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Code) + .Select(x => new { x.Id, x.Code, x.Name }) + .ToListAsync(cancellationToken); + var grades = await ScopedPlans() + .AsNoTracking() + .Select(x => x.EffectiveGrade) + .Distinct() + .OrderByDescending(x => x) + .ToListAsync(cancellationToken); + + return Ok(new { Colleges = colleges, Majors = majorOptions, Grades = grades }); + } + [HttpGet("{id:guid}")] public async Task GetPlan(Guid id, CancellationToken cancellationToken) { diff --git a/src/Jiaowu.Api/Infrastructure/Auth/CourseMaintenancePolicy.cs b/src/Jiaowu.Api/Infrastructure/Auth/CourseMaintenancePolicy.cs new file mode 100644 index 0000000..19099d5 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Auth/CourseMaintenancePolicy.cs @@ -0,0 +1,26 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; + +namespace Jiaowu.Api.Infrastructure.Auth; + +public static class CourseMaintenancePolicy +{ + public static bool CanManage( + CurrentUserScope scope, + Guid collegeId, + CourseNature nature) + { + if (scope.IsInRole(SystemRoles.SuperAdmin) || + scope.IsInRole(SystemRoles.AcademicAdmin)) + return true; + + return scope.IsInRole(SystemRoles.CollegeAdmin) && + scope.CollegeId == collegeId && + IsCollegeMaintainedNature(nature); + } + + public static bool IsCollegeMaintainedNature(CourseNature nature) => + nature is CourseNature.MajorRequired or + CourseNature.MajorElective or + CourseNature.Practice; +} diff --git a/tests/Jiaowu.Api.Tests/DataScopeTests.cs b/tests/Jiaowu.Api.Tests/DataScopeTests.cs index 5a2acaf..e661760 100644 --- a/tests/Jiaowu.Api.Tests/DataScopeTests.cs +++ b/tests/Jiaowu.Api.Tests/DataScopeTests.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Infrastructure.Auth; using Microsoft.AspNetCore.Http; @@ -53,4 +54,49 @@ public sealed class DataScopeTests Assert.True(current.CanAccessCollege(collegeId)); Assert.False(current.CanAccessCollege(Guid.NewGuid())); } + + [Theory] + [InlineData(CourseNature.MajorRequired, true)] + [InlineData(CourseNature.MajorElective, true)] + [InlineData(CourseNature.Practice, true)] + [InlineData(CourseNature.GeneralRequired, false)] + [InlineData(CourseNature.GeneralElective, false)] + public void CollegeAdmin_OnlyMaintainsDelegatedCourseNatures( + CourseNature nature, + bool expected) + { + var collegeId = Guid.NewGuid(); + var scope = new CurrentUserScope( + Guid.NewGuid(), + "学院教务", + collegeId, + DataScope.College, + new HashSet([SystemRoles.CollegeAdmin])); + + Assert.Equal( + expected, + CourseMaintenancePolicy.CanManage(scope, collegeId, nature)); + Assert.False(CourseMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + nature)); + } + + [Theory] + [InlineData(SystemRoles.SuperAdmin)] + [InlineData(SystemRoles.AcademicAdmin)] + public void SchoolAdministrators_RetainWholeCourseCatalogueManagement(string role) + { + var scope = new CurrentUserScope( + Guid.NewGuid(), + "校级教务", + null, + DataScope.All, + new HashSet([role])); + + Assert.True(CourseMaintenancePolicy.CanManage( + scope, + Guid.NewGuid(), + CourseNature.GeneralRequired)); + } } diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 135c44a..c1d9638 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -77,16 +77,16 @@ const navigationGroups = computed(() => [ { path: '/courses', label: '课程库' }, ...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }), ...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }), - ...whenVisible( - hasAnyRole(['SuperAdmin', 'AcademicAdmin']), - { path: '/schedules', label: '排课与课表' }, - ), ], }, { key: 'teaching-operation', label: '教学运行', items: [ + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin']), + { path: '/schedules', label: '排课与课表' }, + ), ...whenVisible( hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']), { diff --git a/web/src/style.css b/web/src/style.css index 905492b..4a2ac00 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -235,9 +235,16 @@ button { cursor: pointer; } .ledger-label span { align-self: flex-start; color: #aeb8d6; font-size: 11px; } .ledger-label b { margin-left: auto; font: 700 31px/1 Consolas, monospace; color: #58d0bf; } .ledger-label small { color: #c4cce3; font-size: 10px; } -.ledger-rule { display: flex; align-items: center; gap: 0; position: relative; z-index: 1; } -.ledger-rule span { padding: 8px 28px; border-right: 1px solid rgba(255,255,255,.12); color: #d2d8e9; font-size: 11px; letter-spacing: .08em; } +.ledger-rule { padding: 17px 28px; display: grid; align-content: center; gap: 4px; position: relative; z-index: 1; } +.ledger-rule span { color: #8fa0cb; font-size: 9px; letter-spacing: .13em; } +.ledger-rule b { color: #f5f7ff; font-size: 14px; font-weight: 650; } +.ledger-rule small { color: #b9c3dd; font-size: 10px; } +.maintenance-scope { min-height: 76px; display: grid; grid-template-columns: 170px 220px 1fr; align-items: stretch; color: white; background: linear-gradient(105deg, #17295a, #263f80 72%, #19736f); overflow: hidden; } +.maintenance-scope > div { padding: 15px 20px; display: grid; align-content: center; gap: 5px; border-right: 1px solid rgba(255,255,255,.13); } +.maintenance-scope span { color: #91a2cc; font-size: 9px; letter-spacing: .12em; } +.maintenance-scope b { color: #f7f9ff; font-size: 13px; font-weight: 650; } +.maintenance-scope p { margin: 0; padding: 0 24px; align-self: center; color: #cbd4e8; font-size: 11px; } .curriculum-filter { padding: 14px 18px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; border: 1px solid var(--line); background: white; } .curriculum-filter > .el-input { width: 260px; } .curriculum-filter > .el-select { width: 170px; } @@ -905,9 +912,11 @@ button { cursor: pointer; } .filter-bar > .el-input { width: 100%; } .filter-bar > .el-select { flex: 1 1 140px; width: auto; } .course-ledger { min-height: auto; display: block; } + .maintenance-scope { grid-template-columns: 1fr 1fr; } + .maintenance-scope p { grid-column: 1 / -1; padding: 12px 18px; border-top: 1px solid rgba(255,255,255,.13); } .ledger-label { width: 100%; } - .ledger-rule { overflow-x: auto; } - .ledger-rule span { padding: 12px 20px; white-space: nowrap; } + .ledger-rule { overflow: visible; } + .ledger-rule span { padding: 0; white-space: nowrap; } .data-card { overflow: hidden; } .curriculum-filter > .el-input { width: 100%; } .curriculum-filter > .el-select { flex: 1 1 140px; width: auto; } diff --git a/web/src/views/CoursesView.vue b/web/src/views/CoursesView.vue index 5516663..14c6444 100644 --- a/web/src/views/CoursesView.vue +++ b/web/src/views/CoursesView.vue @@ -26,7 +26,8 @@ const canManage = computed(() => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role), ) ?? false, ) -const availableColleges = computed(() => +const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false) +const formColleges = computed(() => auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId ? colleges.value.filter((item) => item.id === auth.user!.collegeId) : colleges.value, @@ -42,6 +43,23 @@ const assessmentLabels: Record = { Examination: '考试', Assessment: '考查', } +const collegeNatureLabels = computed(() => { + if (!isCollegeAdmin.value) return natureLabels + return Object.fromEntries( + Object.entries(natureLabels).filter(([value]) => + ['MajorRequired', 'MajorElective', 'Practice'].includes(value), + ), + ) +}) +const maintenanceScope = computed(() => + isCollegeAdmin.value + ? `${formColleges.value[0]?.name ?? '所属学院'} · 专业课与实践课` + : '全校课程 · 全性质', +) + +function canManageRow(row: any) { + return row.canManage === true +} function resetForm(row?: any) { Object.keys(form).forEach((key) => delete form[key]) @@ -54,7 +72,7 @@ function resetForm(row?: any) { totalHours: 32, lectureHours: 24, practiceHours: 8, - nature: 'MajorRequired', + nature: isCollegeAdmin.value ? 'MajorRequired' : 'GeneralRequired', assessmentMethod: 'Examination', description: '', isEnabled: true, @@ -88,7 +106,7 @@ async function resetFilters() { Object.assign(query, { page: 1, keyword: '', - collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined, + collegeId: undefined, nature: undefined, isEnabled: undefined, }) @@ -144,9 +162,6 @@ async function remove(row: any) { onMounted(async () => { colleges.value = (await http.get('/base-data/colleges')).data - if (auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId) { - query.collegeId = auth.user.collegeId - } await load() }) @@ -157,21 +172,21 @@ onMounted(async () => {
COURSE CATALOGUE

课程库

-

维护全校统一课程编码、学分学时和考核方式,供培养方案与教学任务引用。

+

通识课程由校级统筹,专业课与实践课由开课学院维护,统一供培养方案与教学任务引用。

新增课程
- 课程目录 + 当前可见 {{ total }} 门课程
- 编码唯一 - 学时守恒 - 归属明确 + 维护范围 + {{ maintenanceScope }} + {{ isCollegeAdmin ? '通识课程可查看、可引用' : '负责跨学院标准统筹' }}
@@ -185,7 +200,7 @@ onMounted(async () => { @keyup.enter="query.page = 1; load()" /> - + @@ -230,8 +245,11 @@ onMounted(async () => { @@ -258,12 +276,12 @@ onMounted(async () => {
- + - +
diff --git a/web/src/views/CurriculumView.vue b/web/src/views/CurriculumView.vue index 9e3ebfb..f1dcb88 100644 --- a/web/src/views/CurriculumView.vue +++ b/web/src/views/CurriculumView.vue @@ -10,7 +10,9 @@ const detailLoading = ref(false) const plans = ref([]) const total = ref(0) const selected = ref(null) +const colleges = ref([]) const majors = ref([]) +const grades = ref([]) const courses = ref([]) const planDialog = ref(false) const moduleDialog = ref(false) @@ -24,6 +26,7 @@ const query = reactive({ page: 1, pageSize: 20, keyword: '', + collegeId: undefined as string | undefined, majorId: undefined as string | undefined, effectiveGrade: undefined as number | undefined, status: undefined as string | undefined, @@ -42,11 +45,24 @@ const courseTypeLabels: Record = { Required: '必修', Elective: '选修', } -const availableMajors = computed(() => - auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId - ? majors.value.filter((item) => item.collegeId === auth.user!.collegeId) +const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false) +const availableMajors = computed(() => majors.value) +const filteredMajors = computed(() => + query.collegeId + ? majors.value.filter((item) => item.collegeId === query.collegeId) : majors.value, ) +const availableGrades = computed(() => + [...new Set([ + ...grades.value, + ...plans.value.map((item) => Number(item.effectiveGrade)), + ])].sort((first, second) => second - first), +) +const maintenanceScope = computed(() => + isCollegeAdmin.value + ? colleges.value[0]?.name ?? '所属学院' + : '全校各学院', +) const isDraft = computed(() => selected.value?.status === 'Draft') const configuredCredits = computed(() => selected.value?.modules.reduce( @@ -96,6 +112,7 @@ function resetFilters() { Object.assign(query, { page: 1, keyword: '', + collegeId: isCollegeAdmin.value ? auth.user?.collegeId : undefined, majorId: undefined, effectiveGrade: undefined, status: undefined, @@ -103,6 +120,13 @@ function resetFilters() { loadPlans(false) } +function changeCollege() { + if (query.majorId && + !filteredMajors.value.some((item) => item.id === query.majorId)) { + query.majorId = undefined + } +} + function openPlan(row?: any) { editingPlanId.value = row?.id ?? '' Object.keys(planForm).forEach((key) => delete planForm[key]) @@ -268,12 +292,17 @@ async function deleteCourse(moduleId: string, item: any) { } onMounted(async () => { - const [majorRes, courseRes] = await Promise.all([ - http.get('/base-data/majors'), + const [optionRes, courseRes] = await Promise.all([ + http.get('/curriculum-plans/filter-options'), http.get('/courses', { params: { page: 1, pageSize: 100, isEnabled: true } }), ]) - majors.value = majorRes.data + colleges.value = optionRes.data.colleges + majors.value = optionRes.data.majors + grades.value = optionRes.data.grades courses.value = courseRes.data.items + if (isCollegeAdmin.value && auth.user?.collegeId) { + query.collegeId = auth.user.collegeId + } await loadPlans(false) }) @@ -284,17 +313,48 @@ onMounted(async () => {
PROGRAMME OF STUDY

培养方案

-

按专业和入学年级管理版本化课程结构,发布后锁定,避免影响历史年级。

+

由学院按专业和入学年级维护课程结构,发布后锁定,避免影响历史年级。

新建方案 +
+
+ 维护主体 + {{ isCollegeAdmin ? '学院教务' : '校级统筹' }} +
+
+ 当前范围 + {{ maintenanceScope }} +
+

+ {{ isCollegeAdmin + ? '可新建、编制并发布本学院各专业培养方案' + : '可跨学院查看、维护并统筹培养方案版本' }} +

+
+
- - + + + + + + + + -