“教务总览”改成可实际工作的分级工作台:

校级管理员查看全校数据,学院管理员仅查看本学院数据。
待办按当前审批阶段统计,涵盖授课资格、成绩、调停课、学籍异动、教室借用等。
增加学期进度、教学任务发布、课表覆盖、成绩发布状态。
快捷入口根据管理员角色自动调整。
完善未配置学期、无待办、加载失败等状态。
适配桌面和 390px 移动端,无横向溢出。
This commit is contained in:
2026-07-27 17:11:51 +08:00 Unverified
parent b8df564b85
commit 2d1e2cb697
4 changed files with 1789 additions and 221 deletions
+289 -78
View File
@@ -1,11 +1,10 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers; namespace Jiaowu.Api.Controllers;
@@ -14,89 +13,301 @@ namespace Jiaowu.Api.Controllers;
[Route("api/dashboard")] [Route("api/dashboard")]
public sealed class DashboardController( public sealed class DashboardController(
AppDbContext db, AppDbContext db,
IAppCache appCache, ICurrentUserDataScope currentUserDataScope) : ControllerBase
IOptions<JsonOptions> jsonOptions) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken) public async Task<ActionResult<DashboardResponse>> Get(
CancellationToken cancellationToken)
{ {
var response = await appCache.GetOrCreateAsync( var scope = currentUserDataScope.Current;
AppCacheKeys.Dashboard, Guid? restrictedCollegeId = scope.Scope == DataScope.All
LoadAsync, ? null
AppCacheProfile.Analytics, : scope.CollegeId ?? Guid.Empty;
[AppCacheTags.Analytics], var collegeName = restrictedCollegeId.HasValue &&
cancellationToken); restrictedCollegeId.Value != Guid.Empty
return response; ? await db.Colleges.AsNoTracking()
} .Where(x => x.Id == restrictedCollegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
{
var currentTerm = await db.AcademicTerms var currentTerm = await db.AcademicTerms
.AsNoTracking() .AsNoTracking()
.Where(x => x.IsCurrent) .Where(x => x.IsCurrent)
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate }) .Select(x => new DashboardTerm(
x.Id,
x.Name,
x.StartDate,
x.EndDate))
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
var currentTermId = currentTerm?.Id;
return JsonSerializer.SerializeToElement( var students = db.Students.AsNoTracking()
new .Where(x =>
{ !restrictedCollegeId.HasValue ||
CurrentTerm = currentTerm, x.AdministrativeClass!.Major!.CollegeId ==
Counts = new restrictedCollegeId.Value);
{ var teachers = db.Teachers.AsNoTracking()
Campuses = await db.Campuses.CountAsync(cancellationToken), .Where(x =>
Colleges = await db.Colleges.CountAsync(cancellationToken), !restrictedCollegeId.HasValue ||
Majors = await db.Majors.CountAsync(cancellationToken), x.CollegeId == restrictedCollegeId.Value);
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken), var courses = db.Courses.AsNoTracking()
Classrooms = await db.Classrooms.CountAsync(cancellationToken), .Where(x =>
Teachers = await db.Teachers.CountAsync(cancellationToken), !restrictedCollegeId.HasValue ||
Students = await db.Students.CountAsync(cancellationToken), x.CollegeId == restrictedCollegeId.Value);
Courses = await db.Courses.CountAsync(cancellationToken), var teachingTasks = db.TeachingTasks.AsNoTracking()
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken), .Where(x =>
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken), currentTermId.HasValue &&
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken), x.AcademicTermId == currentTermId.Value &&
CourseSelectionRounds = await db.CourseSelectionRounds (!restrictedCollegeId.HasValue ||
.CountAsync(cancellationToken), x.Course!.CollegeId == restrictedCollegeId.Value));
CourseSelectionOfferings = await db.CourseSelectionOfferings var gradeSheets = db.GradeSheets.AsNoTracking()
.CountAsync(cancellationToken), .Where(x =>
CourseEnrollments = await db.CourseEnrollments currentTermId.HasValue &&
.CountAsync( x.TeachingTask!.AcademicTermId == currentTermId.Value &&
x => x.Status == CourseEnrollmentStatus.Enrolled, (!restrictedCollegeId.HasValue ||
cancellationToken), x.TeachingTask.Course!.CollegeId ==
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken), restrictedCollegeId.Value));
PublishedGradeSheets = await db.GradeSheets.CountAsync( var enrollments = db.CourseEnrollments.AsNoTracking()
x => x.Status == GradeSheetStatus.Published, .Where(x =>
cancellationToken), currentTermId.HasValue &&
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken), x.Status == CourseEnrollmentStatus.Enrolled &&
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken), x.CourseSelectionOffering!.CourseSelectionRound!
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken), .AcademicTermId == currentTermId.Value &&
StudentStatusChanges = await db.StudentStatusChanges (!restrictedCollegeId.HasValue ||
.CountAsync(cancellationToken), x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync( restrictedCollegeId.Value));
x => x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved || var taskCount = await teachingTasks.CountAsync(cancellationToken);
x.State == StudentStatusChangeState.CollegeApproved, var publishedTaskCount = await teachingTasks.CountAsync(
cancellationToken), x => x.Status == TeachingTaskStatus.Published,
GraduationAuditBatches = await db.GraduationAuditBatches cancellationToken);
.CountAsync(cancellationToken), var scheduledTaskCount = currentTermId.HasValue
PublishedGraduationAuditBatches = await db.GraduationAuditBatches ? await db.ScheduleEntries.AsNoTracking()
.CountAsync( .Where(x =>
x => x.Status == GraduationAuditBatchStatus.Published, x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
cancellationToken), x.SchedulePlan.Status == SchedulePlanStatus.Published &&
DegreeAwardBatches = await db.DegreeAwardBatches (!restrictedCollegeId.HasValue ||
.CountAsync(cancellationToken), x.TeachingTask!.Course!.CollegeId ==
PublishedDegreeAwardBatches = await db.DegreeAwardBatches restrictedCollegeId.Value))
.CountAsync( .Select(x => x.TeachingTaskId)
x => x.Status == DegreeAwardBatchStatus.Published, .Distinct()
cancellationToken), .CountAsync(cancellationToken)
GraduationClearanceBatches = await db.GraduationClearanceBatches : 0;
.CountAsync(cancellationToken), var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches var publishedGradeSheetCount = await gradeSheets.CountAsync(
.CountAsync( x => x.Status == GradeSheetStatus.Published,
x => x.Status == GraduationClearanceBatchStatus.Open, cancellationToken);
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken) var counts = new DashboardCounts(
} await students.CountAsync(
}, x => x.Status == StudentStatus.Active,
jsonOptions.Value.JsonSerializerOptions); cancellationToken),
await teachers.CountAsync(
x => x.Status == TeacherStatus.Active,
cancellationToken),
await courses.CountAsync(
x => x.IsEnabled,
cancellationToken),
taskCount,
publishedTaskCount,
scheduledTaskCount,
await enrollments.CountAsync(cancellationToken),
gradeSheetCount,
publishedGradeSheetCount,
await gradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Submitted,
cancellationToken),
currentTermId.HasValue
? await db.CourseSelectionRounds.AsNoTracking().CountAsync(
x => x.AcademicTermId == currentTermId.Value &&
x.Status == CourseSelectionRoundStatus.Open,
cancellationToken)
: 0);
var pending = await LoadPendingAsync(
scope,
restrictedCollegeId,
cancellationToken);
return Ok(new DashboardResponse(
BuildAudience(scope, collegeName),
currentTerm,
counts,
pending,
DateTime.UtcNow));
}
private async Task<DashboardPending> LoadPendingAsync(
CurrentUserScope scope,
Guid? restrictedCollegeId,
CancellationToken cancellationToken)
{
var isSchoolManager =
scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin);
var isCollegeManager =
!isSchoolManager && scope.IsInRole(SystemRoles.CollegeAdmin);
if (!isSchoolManager && !isCollegeManager)
return new DashboardPending(0, 0, 0, 0, 0, 0, 0);
var teacherApplications = db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.Status == TeacherCourseApplicationStatus.Pending &&
(!restrictedCollegeId.HasValue ||
x.Teacher!.CollegeId == restrictedCollegeId.Value));
var gradeSheets = db.GradeSheets.AsNoTracking()
.Where(x =>
x.Status == GradeSheetStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var courseAdjustments = db.CourseAdjustments.AsNoTracking()
.Where(x =>
x.Status == CourseAdjustmentStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var studentStatusChanges = db.StudentStatusChanges.AsNoTracking()
.Where(x =>
x.State == (isCollegeManager
? StudentStatusChangeState.CounselorApproved
: StudentStatusChangeState.CollegeApproved) &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value));
var gradeModifications = db.GradeModifications.AsNoTracking()
.Where(x =>
x.Status == (isCollegeManager
? GradeModificationStatus.TeacherSubmitted
: GradeModificationStatus.CollegeApproved) &&
(!restrictedCollegeId.HasValue ||
x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var generalApprovals =
await db.CourseExemptions.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.DeferredExams.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.CourseSubstitutions.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.AttendanceRecords.AsNoTracking().CountAsync(
x => x.AppealStatus == AttendanceAppealStatus.Pending &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken);
var classroomReservations = isCollegeManager &&
restrictedCollegeId.HasValue
? await db.ClassroomReservations.AsNoTracking().CountAsync(
x => x.Status == ClassroomReservationStatus.Submitted &&
x.ApplicantCollegeId == restrictedCollegeId.Value,
cancellationToken)
: 0;
return new DashboardPending(
await teacherApplications.CountAsync(cancellationToken),
await gradeSheets.CountAsync(cancellationToken),
await courseAdjustments.CountAsync(cancellationToken),
await studentStatusChanges.CountAsync(cancellationToken),
await gradeModifications.CountAsync(cancellationToken),
classroomReservations,
generalApprovals);
}
private static DashboardAudience BuildAudience(
CurrentUserScope scope,
string? collegeName)
{
if (scope.IsInRole(SystemRoles.SuperAdmin))
return new DashboardAudience(
"System",
"全域教务工作台",
"全校",
"统筹基础数据、教学运行与系统治理");
if (scope.IsInRole(SystemRoles.AcademicAdmin))
return new DashboardAudience(
"School",
"校级教务工作台",
"全校",
"聚焦跨学院教学运行与校级审核");
if (scope.IsInRole(SystemRoles.CollegeAdmin))
return new DashboardAudience(
"College",
"学院教务工作台",
collegeName ?? "本学院",
"聚焦本学院教学准备、过程审核与成绩归档");
if (scope.IsInRole(SystemRoles.Leader))
return new DashboardAudience(
"Leadership",
"教学运行观察台",
"全校",
"查看全校教学运行与质量数据");
if (scope.IsInRole(SystemRoles.Counselor))
return new DashboardAudience(
"Counselor",
"班级工作台",
collegeName ?? "所辖班级",
"处理学生过程管理与学业支持");
return new DashboardAudience(
"Teaching",
"教学工作台",
collegeName ?? "个人教学",
"查看课程运行并进入日常教学工作");
} }
} }
public sealed record DashboardResponse(
DashboardAudience Audience,
DashboardTerm? CurrentTerm,
DashboardCounts Counts,
DashboardPending Pending,
DateTime GeneratedAt);
public sealed record DashboardAudience(
string Level,
string Title,
string ScopeName,
string Description);
public sealed record DashboardTerm(
Guid Id,
string Name,
DateOnly StartDate,
DateOnly EndDate);
public sealed record DashboardCounts(
int Students,
int Teachers,
int Courses,
int TeachingTasks,
int PublishedTeachingTasks,
int ScheduledTeachingTasks,
int CourseEnrollments,
int GradeSheets,
int PublishedGradeSheets,
int SubmittedGradeSheets,
int OpenCourseSelectionRounds);
public sealed record DashboardPending(
int TeacherApplications,
int GradeSheets,
int CourseAdjustments,
int StudentStatusChanges,
int GradeModifications,
int ClassroomReservations,
int GeneralApprovals);
@@ -0,0 +1,261 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class DashboardControllerTests
{
[Fact]
public async Task College_dashboard_is_scoped_and_only_counts_actionable_stage()
{
await using var database = await DashboardDatabase.CreateAsync();
var controller = new DashboardController(
database.Db,
new TestDataScope(
database.FirstCollegeId,
DataScope.College,
SystemRoles.CollegeAdmin));
var result = await controller.Get(CancellationToken.None);
var response = Assert.IsType<DashboardResponse>(
Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.Equal("College", response.Audience.Level);
Assert.Equal("第一学院", response.Audience.ScopeName);
Assert.Equal(1, response.Counts.Students);
Assert.Equal(1, response.Counts.Teachers);
Assert.Equal(1, response.Counts.TeachingTasks);
Assert.Equal(1, response.Pending.TeacherApplications);
Assert.Equal(1, response.Pending.StudentStatusChanges);
}
[Fact]
public async Task School_dashboard_uses_school_stage_and_all_colleges()
{
await using var database = await DashboardDatabase.CreateAsync();
var controller = new DashboardController(
database.Db,
new TestDataScope(
null,
DataScope.All,
SystemRoles.AcademicAdmin));
var result = await controller.Get(CancellationToken.None);
var response = Assert.IsType<DashboardResponse>(
Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.Equal("School", response.Audience.Level);
Assert.Equal(2, response.Counts.Students);
Assert.Equal(2, response.Counts.Teachers);
Assert.Equal(2, response.Counts.TeachingTasks);
Assert.Equal(2, response.Pending.TeacherApplications);
Assert.Equal(1, response.Pending.StudentStatusChanges);
}
private sealed class DashboardDatabase : IAsyncDisposable
{
private readonly SqliteConnection connection;
private DashboardDatabase(
SqliteConnection connection,
AppDbContext db,
Guid firstCollegeId)
{
this.connection = connection;
Db = db;
FirstCollegeId = firstCollegeId;
}
public AppDbContext Db { get; }
public Guid FirstCollegeId { get; }
public static async Task<DashboardDatabase> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15),
IsCurrent = true
};
var firstCollege = new College
{
Code = "C01",
Name = "第一学院"
};
var secondCollege = new College
{
Code = "C02",
Name = "第二学院"
};
var firstMajor = CreateMajor("M01", firstCollege);
var secondMajor = CreateMajor("M02", secondCollege);
var firstClass = CreateClass("CL01", firstMajor);
var secondClass = CreateClass("CL02", secondMajor);
var firstStudent = CreateStudent("S01", firstClass);
var secondStudent = CreateStudent("S02", secondClass);
var firstTeacher = CreateTeacher("T01", firstCollege);
var secondTeacher = CreateTeacher("T02", secondCollege);
var firstCourse = CreateCourse("COURSE01", firstCollege);
var secondCourse = CreateCourse("COURSE02", secondCollege);
var firstTask = CreateTask("TASK01", term, firstCourse);
var secondTask = CreateTask("TASK02", term, secondCourse);
db.AddRange(
term,
firstCollege,
secondCollege,
firstMajor,
secondMajor,
firstClass,
secondClass,
firstStudent,
secondStudent,
firstTeacher,
secondTeacher,
firstCourse,
secondCourse,
firstTask,
secondTask,
new TeacherCourseApplication
{
AcademicTerm = term,
Teacher = firstTeacher,
Course = firstCourse,
Status = TeacherCourseApplicationStatus.Pending
},
new TeacherCourseApplication
{
AcademicTerm = term,
Teacher = secondTeacher,
Course = secondCourse,
Status = TeacherCourseApplicationStatus.Pending
},
CreateStatusChange(
firstStudent,
StudentStatusChangeState.CounselorApproved),
CreateStatusChange(
firstStudent,
StudentStatusChangeState.CollegeApproved),
CreateStatusChange(
secondStudent,
StudentStatusChangeState.Submitted));
await db.SaveChangesAsync();
return new DashboardDatabase(connection, db, firstCollege.Id);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
private static Major CreateMajor(string code, College college) => new()
{
Code = code,
Name = $"专业 {code}",
College = college,
DegreeType = "本科"
};
private static AdministrativeClass CreateClass(
string code,
Major major) => new()
{
Code = code,
Name = $"班级 {code}",
Major = major,
Grade = 2026
};
private static Student CreateStudent(
string number,
AdministrativeClass administrativeClass) => new()
{
StudentNumber = number,
Name = $"学生 {number}",
AdministrativeClass = administrativeClass,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1),
Status = StudentStatus.Active
};
private static Teacher CreateTeacher(
string number,
College college) => new()
{
TeacherNumber = number,
Name = $"教师 {number}",
College = college,
Status = TeacherStatus.Active
};
private static Course CreateCourse(string code, College college) => new()
{
Code = code,
Name = $"课程 {code}",
College = college,
Nature = CourseNature.MajorRequired,
Credits = 2,
TotalHours = 32,
LectureHours = 32,
AssessmentMethod = AssessmentMethod.Examination
};
private static TeachingTask CreateTask(
string number,
AcademicTerm term,
Course course) => new()
{
TaskNumber = number,
Name = $"教学班 {number}",
AcademicTerm = term,
Course = course,
Capacity = 40,
Status = TeachingTaskStatus.Published
};
private static StudentStatusChange CreateStatusChange(
Student student,
StudentStatusChangeState state) => new()
{
Student = student,
Type = StudentStatusChangeType.Suspension,
OriginalStatus = StudentStatus.Active,
TargetStatus = StudentStatus.Suspended,
Reason = "测试学籍异动流程",
State = state
};
}
private sealed class TestDataScope(
Guid? collegeId,
DataScope dataScope,
string role) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
collegeId,
dataScope,
new HashSet<string> { role });
}
}
+1
View File
@@ -38,6 +38,7 @@ declare module 'vue' {
ElProgress: typeof import('element-plus/es')['ElProgress'] ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElSegmented: typeof import('element-plus/es')['ElSegmented'] ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider'] ElSlider: typeof import('element-plus/es')['ElSlider']
File diff suppressed because it is too large Load Diff