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> 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> 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 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 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 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);