培养方案下放学院维护,学院账号被后端限制在本学院范围;新增按年级、学院、专业联动筛选及维护范围提示。
课程库实行分级维护:学院只能维护本学院的专业必修、专业选修和实践课程;通识课程可查看、引用但不可修改。校级账号保留全校统筹权限。 “排课与课表”已从“教学建设”迁移到“教学运行”,原路由保持不变。 权限规则同时落实在前端和 API,并新增专项测试。
This commit is contained in:
@@ -34,6 +34,11 @@ public sealed class CoursesController(
|
|||||||
{
|
{
|
||||||
page = Math.Max(1, page);
|
page = Math.Max(1, page);
|
||||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
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();
|
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);
|
||||||
@@ -73,7 +78,13 @@ public sealed class CoursesController(
|
|||||||
x.Description,
|
x.Description,
|
||||||
x.IsEnabled,
|
x.IsEnabled,
|
||||||
x.SortOrder,
|
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);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -118,7 +129,7 @@ public sealed class CoursesController(
|
|||||||
{
|
{
|
||||||
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
||||||
if (entity is null) return NotFound();
|
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);
|
var validation = await ValidateAsync(request, cancellationToken);
|
||||||
if (validation is not null) return validation;
|
if (validation is not null) return validation;
|
||||||
|
|
||||||
@@ -144,7 +155,7 @@ public sealed class CoursesController(
|
|||||||
{
|
{
|
||||||
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
var entity = await db.Courses.FindAsync([id], cancellationToken);
|
||||||
if (entity is null) return NotFound();
|
if (entity is null) return NotFound();
|
||||||
if (!CanAccessCollege(entity.CollegeId)) return Forbid();
|
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
|
||||||
db.Courses.Remove(entity);
|
db.Courses.Remove(entity);
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -153,7 +164,7 @@ public sealed class CoursesController(
|
|||||||
CourseRequest request,
|
CourseRequest request,
|
||||||
CancellationToken cancellationToken)
|
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))
|
if (!await db.Colleges.AnyAsync(x => x.Id == request.CollegeId, cancellationToken))
|
||||||
return ValidationProblem("所选学院不存在。");
|
return ValidationProblem("所选学院不存在。");
|
||||||
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
if (request.LectureHours + request.PracticeHours > request.TotalHours)
|
||||||
@@ -167,7 +178,12 @@ public sealed class CoursesController(
|
|||||||
var source = db.Courses.AsQueryable();
|
var source = db.Courses.AsQueryable();
|
||||||
if (scope.Scope == DataScope.All) return source;
|
if (scope.Scope == DataScope.All) return source;
|
||||||
if (scope.Scope == DataScope.College)
|
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)
|
if (scope.Scope == DataScope.Class)
|
||||||
{
|
{
|
||||||
return source.Where(course => db.TeachingTasks.Any(task =>
|
return source.Where(course => db.TeachingTasks.Any(task =>
|
||||||
@@ -190,8 +206,11 @@ public sealed class CoursesController(
|
|||||||
student.UserId == userId))));
|
student.UserId == userId))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanAccessCollege(Guid collegeId) =>
|
private bool CanManage(Guid collegeId, CourseNature nature)
|
||||||
currentUserDataScope.Current.CanAccessCollege(collegeId);
|
=> CourseMaintenancePolicy.CanManage(
|
||||||
|
currentUserDataScope.Current,
|
||||||
|
collegeId,
|
||||||
|
nature);
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(
|
private async Task<ActionResult> SaveAsync(
|
||||||
Guid id,
|
Guid id,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public sealed class CurriculumPlansController(
|
|||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 20,
|
int pageSize = 20,
|
||||||
string? keyword = null,
|
string? keyword = null,
|
||||||
|
Guid? collegeId = null,
|
||||||
Guid? majorId = null,
|
Guid? majorId = null,
|
||||||
int? effectiveGrade = null,
|
int? effectiveGrade = null,
|
||||||
CurriculumPlanStatus? status = null,
|
CurriculumPlanStatus? status = null,
|
||||||
@@ -35,6 +36,8 @@ public sealed class CurriculumPlansController(
|
|||||||
page = Math.Max(1, page);
|
page = Math.Max(1, page);
|
||||||
pageSize = Math.Clamp(pageSize, 10, 100);
|
pageSize = Math.Clamp(pageSize, 10, 100);
|
||||||
var source = ScopedPlans().AsNoTracking();
|
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 (majorId.HasValue) source = source.Where(x => x.MajorId == majorId);
|
||||||
if (effectiveGrade.HasValue)
|
if (effectiveGrade.HasValue)
|
||||||
source = source.Where(x => x.EffectiveGrade == effectiveGrade);
|
source = source.Where(x => x.EffectiveGrade == effectiveGrade);
|
||||||
@@ -76,6 +79,49 @@ public sealed class CurriculumPlansController(
|
|||||||
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
return Ok(new PagedResult<object>(items, total, page, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("filter-options")]
|
||||||
|
public async Task<ActionResult> 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}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> GetPlan(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -53,4 +54,49 @@ public sealed class DataScopeTests
|
|||||||
Assert.True(current.CanAccessCollege(collegeId));
|
Assert.True(current.CanAccessCollege(collegeId));
|
||||||
Assert.False(current.CanAccessCollege(Guid.NewGuid()));
|
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<string>([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<string>([role]));
|
||||||
|
|
||||||
|
Assert.True(CourseMaintenancePolicy.CanManage(
|
||||||
|
scope,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
CourseNature.GeneralRequired));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,16 +77,16 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
|||||||
{ 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: '教学任务' }),
|
||||||
...whenVisible(
|
|
||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
|
||||||
{ path: '/schedules', label: '排课与课表' },
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'teaching-operation',
|
key: 'teaching-operation',
|
||||||
label: '教学运行',
|
label: '教学运行',
|
||||||
items: [
|
items: [
|
||||||
|
...whenVisible(
|
||||||
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||||
|
{ path: '/schedules', label: '排课与课表' },
|
||||||
|
),
|
||||||
...whenVisible(
|
...whenVisible(
|
||||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
|
||||||
{
|
{
|
||||||
|
|||||||
+13
-4
@@ -235,9 +235,16 @@ button { cursor: pointer; }
|
|||||||
.ledger-label span { align-self: flex-start; color: #aeb8d6; font-size: 11px; }
|
.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 b { margin-left: auto; font: 700 31px/1 Consolas, monospace; color: #58d0bf; }
|
||||||
.ledger-label small { color: #c4cce3; font-size: 10px; }
|
.ledger-label small { color: #c4cce3; font-size: 10px; }
|
||||||
.ledger-rule { display: flex; align-items: center; gap: 0; position: relative; z-index: 1; }
|
.ledger-rule { padding: 17px 28px; display: grid; align-content: center; gap: 4px; 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 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 { 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-input { width: 260px; }
|
||||||
.curriculum-filter > .el-select { width: 170px; }
|
.curriculum-filter > .el-select { width: 170px; }
|
||||||
@@ -905,9 +912,11 @@ button { cursor: pointer; }
|
|||||||
.filter-bar > .el-input { width: 100%; }
|
.filter-bar > .el-input { width: 100%; }
|
||||||
.filter-bar > .el-select { flex: 1 1 140px; width: auto; }
|
.filter-bar > .el-select { flex: 1 1 140px; width: auto; }
|
||||||
.course-ledger { min-height: auto; display: block; }
|
.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-label { width: 100%; }
|
||||||
.ledger-rule { overflow-x: auto; }
|
.ledger-rule { overflow: visible; }
|
||||||
.ledger-rule span { padding: 12px 20px; white-space: nowrap; }
|
.ledger-rule span { padding: 0; white-space: nowrap; }
|
||||||
.data-card { overflow: hidden; }
|
.data-card { overflow: hidden; }
|
||||||
.curriculum-filter > .el-input { width: 100%; }
|
.curriculum-filter > .el-input { width: 100%; }
|
||||||
.curriculum-filter > .el-select { flex: 1 1 140px; width: auto; }
|
.curriculum-filter > .el-select { flex: 1 1 140px; width: auto; }
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const canManage = computed(() =>
|
|||||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
||||||
) ?? false,
|
) ?? false,
|
||||||
)
|
)
|
||||||
const availableColleges = computed(() =>
|
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
|
||||||
|
const formColleges = computed(() =>
|
||||||
auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId
|
auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId
|
||||||
? colleges.value.filter((item) => item.id === auth.user!.collegeId)
|
? colleges.value.filter((item) => item.id === auth.user!.collegeId)
|
||||||
: colleges.value,
|
: colleges.value,
|
||||||
@@ -42,6 +43,23 @@ const assessmentLabels: Record<string, string> = {
|
|||||||
Examination: '考试',
|
Examination: '考试',
|
||||||
Assessment: '考查',
|
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) {
|
function resetForm(row?: any) {
|
||||||
Object.keys(form).forEach((key) => delete form[key])
|
Object.keys(form).forEach((key) => delete form[key])
|
||||||
@@ -54,7 +72,7 @@ function resetForm(row?: any) {
|
|||||||
totalHours: 32,
|
totalHours: 32,
|
||||||
lectureHours: 24,
|
lectureHours: 24,
|
||||||
practiceHours: 8,
|
practiceHours: 8,
|
||||||
nature: 'MajorRequired',
|
nature: isCollegeAdmin.value ? 'MajorRequired' : 'GeneralRequired',
|
||||||
assessmentMethod: 'Examination',
|
assessmentMethod: 'Examination',
|
||||||
description: '',
|
description: '',
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
@@ -88,7 +106,7 @@ async function resetFilters() {
|
|||||||
Object.assign(query, {
|
Object.assign(query, {
|
||||||
page: 1,
|
page: 1,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
collegeId: auth.user?.roles.includes('CollegeAdmin') ? auth.user.collegeId : undefined,
|
collegeId: undefined,
|
||||||
nature: undefined,
|
nature: undefined,
|
||||||
isEnabled: undefined,
|
isEnabled: undefined,
|
||||||
})
|
})
|
||||||
@@ -144,9 +162,6 @@ async function remove(row: any) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
colleges.value = (await http.get('/base-data/colleges')).data
|
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()
|
await load()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -157,21 +172,21 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<span class="section-kicker">COURSE CATALOGUE</span>
|
<span class="section-kicker">COURSE CATALOGUE</span>
|
||||||
<h2>课程库</h2>
|
<h2>课程库</h2>
|
||||||
<p>维护全校统一课程编码、学分学时和考核方式,供培养方案与教学任务引用。</p>
|
<p>通识课程由校级统筹,专业课与实践课由开课学院维护,统一供培养方案与教学任务引用。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
|
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增课程</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="course-ledger">
|
<section class="course-ledger">
|
||||||
<div class="ledger-label">
|
<div class="ledger-label">
|
||||||
<span>课程目录</span>
|
<span>当前可见</span>
|
||||||
<b>{{ total }}</b>
|
<b>{{ total }}</b>
|
||||||
<small>门课程</small>
|
<small>门课程</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="ledger-rule">
|
<div class="ledger-rule">
|
||||||
<span>编码唯一</span>
|
<span>维护范围</span>
|
||||||
<span>学时守恒</span>
|
<b>{{ maintenanceScope }}</b>
|
||||||
<span>归属明确</span>
|
<small>{{ isCollegeAdmin ? '通识课程可查看、可引用' : '负责跨学院标准统筹' }}</small>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -185,7 +200,7 @@ onMounted(async () => {
|
|||||||
@keyup.enter="query.page = 1; load()"
|
@keyup.enter="query.page = 1; load()"
|
||||||
/>
|
/>
|
||||||
<el-select v-model="query.collegeId" clearable placeholder="全部开课学院">
|
<el-select v-model="query.collegeId" clearable placeholder="全部开课学院">
|
||||||
<el-option v-for="item in availableColleges" :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.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" />
|
||||||
@@ -230,8 +245,11 @@ onMounted(async () => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="canManage" label="操作" width="145" fixed="right">
|
<el-table-column v-if="canManage" label="操作" width="145" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
<template v-if="canManageRow(row)">
|
||||||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted-action">校级维护</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="没有符合条件的课程" /></template>
|
<template #empty><el-empty description="没有符合条件的课程" /></template>
|
||||||
@@ -258,12 +276,12 @@ onMounted(async () => {
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<el-form-item label="开课学院" required>
|
<el-form-item label="开课学院" required>
|
||||||
<el-select v-model="form.collegeId">
|
<el-select v-model="form.collegeId">
|
||||||
<el-option v-for="item in availableColleges" :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="课程性质">
|
<el-form-item label="课程性质">
|
||||||
<el-select v-model="form.nature">
|
<el-select v-model="form.nature">
|
||||||
<el-option v-for="(label, value) in natureLabels" :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>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const detailLoading = ref(false)
|
|||||||
const plans = ref<any[]>([])
|
const plans = ref<any[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const selected = ref<any | null>(null)
|
const selected = ref<any | null>(null)
|
||||||
|
const colleges = ref<any[]>([])
|
||||||
const majors = ref<any[]>([])
|
const majors = ref<any[]>([])
|
||||||
|
const grades = ref<number[]>([])
|
||||||
const courses = ref<any[]>([])
|
const courses = ref<any[]>([])
|
||||||
const planDialog = ref(false)
|
const planDialog = ref(false)
|
||||||
const moduleDialog = ref(false)
|
const moduleDialog = ref(false)
|
||||||
@@ -24,6 +26,7 @@ const query = reactive({
|
|||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
|
collegeId: undefined as string | undefined,
|
||||||
majorId: undefined as string | undefined,
|
majorId: undefined as string | undefined,
|
||||||
effectiveGrade: undefined as number | undefined,
|
effectiveGrade: undefined as number | undefined,
|
||||||
status: undefined as string | undefined,
|
status: undefined as string | undefined,
|
||||||
@@ -42,11 +45,24 @@ const courseTypeLabels: Record<string, string> = {
|
|||||||
Required: '必修',
|
Required: '必修',
|
||||||
Elective: '选修',
|
Elective: '选修',
|
||||||
}
|
}
|
||||||
const availableMajors = computed(() =>
|
const isCollegeAdmin = computed(() => auth.user?.roles.includes('CollegeAdmin') ?? false)
|
||||||
auth.user?.roles.includes('CollegeAdmin') && auth.user.collegeId
|
const availableMajors = computed(() => majors.value)
|
||||||
? majors.value.filter((item) => item.collegeId === auth.user!.collegeId)
|
const filteredMajors = computed(() =>
|
||||||
|
query.collegeId
|
||||||
|
? majors.value.filter((item) => item.collegeId === query.collegeId)
|
||||||
: majors.value,
|
: 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 isDraft = computed(() => selected.value?.status === 'Draft')
|
||||||
const configuredCredits = computed(() =>
|
const configuredCredits = computed(() =>
|
||||||
selected.value?.modules.reduce(
|
selected.value?.modules.reduce(
|
||||||
@@ -96,6 +112,7 @@ function resetFilters() {
|
|||||||
Object.assign(query, {
|
Object.assign(query, {
|
||||||
page: 1,
|
page: 1,
|
||||||
keyword: '',
|
keyword: '',
|
||||||
|
collegeId: isCollegeAdmin.value ? auth.user?.collegeId : undefined,
|
||||||
majorId: undefined,
|
majorId: undefined,
|
||||||
effectiveGrade: undefined,
|
effectiveGrade: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -103,6 +120,13 @@ function resetFilters() {
|
|||||||
loadPlans(false)
|
loadPlans(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeCollege() {
|
||||||
|
if (query.majorId &&
|
||||||
|
!filteredMajors.value.some((item) => item.id === query.majorId)) {
|
||||||
|
query.majorId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openPlan(row?: any) {
|
function openPlan(row?: any) {
|
||||||
editingPlanId.value = row?.id ?? ''
|
editingPlanId.value = row?.id ?? ''
|
||||||
Object.keys(planForm).forEach((key) => delete planForm[key])
|
Object.keys(planForm).forEach((key) => delete planForm[key])
|
||||||
@@ -268,12 +292,17 @@ async function deleteCourse(moduleId: string, item: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [majorRes, courseRes] = await Promise.all([
|
const [optionRes, courseRes] = await Promise.all([
|
||||||
http.get('/base-data/majors'),
|
http.get('/curriculum-plans/filter-options'),
|
||||||
http.get('/courses', { params: { page: 1, pageSize: 100, isEnabled: true } }),
|
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
|
courses.value = courseRes.data.items
|
||||||
|
if (isCollegeAdmin.value && auth.user?.collegeId) {
|
||||||
|
query.collegeId = auth.user.collegeId
|
||||||
|
}
|
||||||
await loadPlans(false)
|
await loadPlans(false)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -284,17 +313,48 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<span class="section-kicker">PROGRAMME OF STUDY</span>
|
<span class="section-kicker">PROGRAMME OF STUDY</span>
|
||||||
<h2>培养方案</h2>
|
<h2>培养方案</h2>
|
||||||
<p>按专业和入学年级管理版本化课程结构,发布后锁定,避免影响历史年级。</p>
|
<p>由学院按专业和入学年级维护课程结构,发布后锁定,避免影响历史年级。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
|
<el-button type="primary" :icon="Plus" @click="openPlan()">新建方案</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="maintenance-scope" aria-label="培养方案维护范围">
|
||||||
|
<div>
|
||||||
|
<span>维护主体</span>
|
||||||
|
<b>{{ isCollegeAdmin ? '学院教务' : '校级统筹' }}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>当前范围</span>
|
||||||
|
<b>{{ maintenanceScope }}</b>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
{{ isCollegeAdmin
|
||||||
|
? '可新建、编制并发布本学院各专业培养方案'
|
||||||
|
: '可跨学院查看、维护并统筹培养方案版本' }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="curriculum-filter">
|
<section class="curriculum-filter">
|
||||||
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索方案、版本或专业" @keyup.enter="query.page = 1; loadPlans(false)" />
|
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索方案、版本或专业" @keyup.enter="query.page = 1; loadPlans(false)" />
|
||||||
<el-select v-model="query.majorId" clearable filterable placeholder="全部专业">
|
<el-select
|
||||||
<el-option v-for="item in availableMajors" :key="item.id" :label="item.name" :value="item.id" />
|
v-model="query.effectiveGrade"
|
||||||
|
clearable
|
||||||
|
placeholder="全部年级"
|
||||||
|
>
|
||||||
|
<el-option v-for="grade in availableGrades" :key="grade" :label="`${grade} 级`" :value="grade" />
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="query.collegeId"
|
||||||
|
clearable
|
||||||
|
:disabled="isCollegeAdmin"
|
||||||
|
placeholder="全部学院"
|
||||||
|
@change="changeCollege"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in colleges" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="query.majorId" clearable filterable placeholder="全部专业">
|
||||||
|
<el-option v-for="item in filteredMajors" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-input-number v-model="query.effectiveGrade" :min="2000" :max="2200" placeholder="适用年级" />
|
|
||||||
<el-select v-model="query.status" clearable placeholder="全部状态">
|
<el-select v-model="query.status" clearable placeholder="全部状态">
|
||||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|||||||
Reference in New Issue
Block a user