Files
Academic-Affairs-System/src/Jiaowu.Api/Controllers/DashboardController.cs
T
2026-08-10 10:19:19 +08:00

499 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/dashboard")]
public sealed class DashboardController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<DashboardResponse>> Get(
CancellationToken cancellationToken)
{
var scope = currentUserDataScope.Current;
Guid? restrictedCollegeId = scope.Scope == DataScope.All
? null
: scope.CollegeId ?? Guid.Empty;
var collegeName = restrictedCollegeId.HasValue &&
restrictedCollegeId.Value != Guid.Empty
? await db.Colleges.AsNoTracking()
.Where(x => x.Id == restrictedCollegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var currentTerm = await db.AcademicTerms
.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => new DashboardTerm(
x.Id,
x.Name,
x.StartDate,
x.EndDate))
.FirstOrDefaultAsync(cancellationToken);
var currentTermId = currentTerm?.Id;
var students = db.Students.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value);
var teachers = db.Teachers.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.CollegeId == restrictedCollegeId.Value);
var courses = db.Courses.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.CollegeId == restrictedCollegeId.Value);
var teachingTasks = db.TeachingTasks.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.Course!.CollegeId == restrictedCollegeId.Value));
var gradeSheets = db.GradeSheets.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.TeachingTask!.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask.Course!.CollegeId ==
restrictedCollegeId.Value));
var enrollments = db.CourseEnrollments.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!
.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var taskCount = await teachingTasks.CountAsync(cancellationToken);
var publishedTaskCount = await teachingTasks.CountAsync(
x => x.Status == TeachingTaskStatus.Published,
cancellationToken);
var scheduledTaskCount = currentTermId.HasValue
? await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
x.SchedulePlan.Status == SchedulePlanStatus.Published &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value))
.Select(x => x.TeachingTaskId)
.Distinct()
.CountAsync(cancellationToken)
: 0;
var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
var publishedGradeSheetCount = await gradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Published,
cancellationToken);
var counts = new DashboardCounts(
await students.CountAsync(
x => x.Status == StudentStatus.Active,
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,
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
DateTime.UtcNow));
}
[HttpGet("greeting")]
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
CancellationToken cancellationToken)
{
var currentTermId = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
return Ok(await BuildGreetingAsync(
currentUserDataScope.Current,
currentTermId,
null,
null,
cancellationToken));
}
private async Task<DashboardGreeting> BuildGreetingAsync(
CurrentUserScope scope,
Guid? currentTermId,
DashboardCounts? counts,
DashboardPending? pending,
CancellationToken cancellationToken)
{
var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName}";
var greeting = GetTimeGreeting();
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Leader) ||
scope.IsInRole(SystemRoles.Counselor);
if (!isManager && scope.IsInRole(SystemRoles.Student))
{
var studentId = await db.Students.AsNoTracking()
.Where(x => x.UserId == scope.UserId)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!studentId.HasValue)
return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
var student = await db.Students.AsNoTracking()
.Where(x => x.Id == studentId.Value)
.Select(x => new { x.AdministrativeClassId })
.FirstAsync(cancellationToken);
var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
currentTermId.HasValue &&
task.AcademicTermId == currentTermId.Value &&
task.Status == TeachingTaskStatus.Published &&
(task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == studentId.Value &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
var taskWorkload = await currentTasks.Select(task => new
{
task.Id,
task.CourseId,
Credits = task.Course!.Credits,
IsClassAssigned = task.Classes.Any(item =>
item.AdministrativeClassId == student.AdministrativeClassId)
}).ToListAsync(cancellationToken);
var currentCourses = taskWorkload
.GroupBy(x => x.CourseId)
.Select(x => x.First())
.ToList();
var courseCount = currentCourses.Count;
var courseCredits = currentCourses.Sum(x => x.Credits);
var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
x.StudentId == studentId.Value &&
x.GradeSheet!.Status == GradeSheetStatus.Published &&
currentTermId.HasValue &&
x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
var gradeCount = await publishedGrades.CountAsync(cancellationToken);
var average = await publishedGrades
.Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
.AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
var failed = await publishedGrades.CountAsync(x =>
x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
cancellationToken);
var subtitle = failed > 0
? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
: courseCount > 0
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
var narrative = courseCount == 0
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
: failed > 0
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
: gradeCount > 0
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
[
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
]);
}
if (!isManager && scope.IsInRole(SystemRoles.Teacher))
{
var teacherId = await db.Teachers.AsNoTracking()
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
.Select(x => (Guid?)x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (!teacherId.HasValue)
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
var teachingClasses = await tasks.CountAsync(cancellationToken);
var estimatedHours = await tasks.SumAsync(
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
var pendingGrades = await gradeSheets.CountAsync(x =>
x.Status == GradeSheetStatus.Draft ||
x.Status == GradeSheetStatus.Returned, cancellationToken);
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
var subtitle = pendingGrades > 0
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
: teachingClasses > 0
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
: "本学期暂未分配教学班,请留意教学任务安排。";
var narrative = pendingGrades > 0
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
[
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
]);
}
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
pending.ClassroomReservations + pending.GeneralApprovals;
var taskCount = counts?.TeachingTasks ?? 0;
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
var subtitleForManager = actionable > 0
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
: taskCount > 0
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
: "当前学期运行数据已就绪,可从教学任务开始推进。";
var managerNarrative = actionable > 0
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
[
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
]);
}
private static string GetTimeGreeting()
{
var hour = DateTime.UtcNow.AddHours(8).Hour;
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
}
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,
DashboardGreeting Greeting,
DateTime GeneratedAt);
public sealed record DashboardGreeting(
string Role,
string Title,
string Subtitle,
string Label,
string Narrative,
IReadOnlyList<DashboardGreetingInsight> Insights);
public sealed record DashboardGreetingInsight(
string Label,
string Value,
string Hint,
string Tone);
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);