diff --git a/Academic-Affairs-System.wiki b/Academic-Affairs-System.wiki new file mode 160000 index 0000000..c7be143 --- /dev/null +++ b/Academic-Affairs-System.wiki @@ -0,0 +1 @@ +Subproject commit c7be1438bcdad0553e3c29b4fcc8c7f97a058131 diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index 3478503..61ef7fc 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -135,9 +135,179 @@ public sealed class DashboardController( 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, @@ -276,8 +446,23 @@ public sealed record DashboardResponse( 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, diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index 2ed149e..07a361d 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -354,6 +354,25 @@ public sealed class SchedulesController( ToResponse(job)); } + [HttpGet("plans/{planId:guid}/preflight")] + public async Task Preflight(Guid planId, CancellationToken cancellationToken) + { + var plan = await DraftPlanAsync(planId, cancellationToken); + if (plan is null) return NotFound(); + var tasks = await db.TeachingTasks.AsNoTracking() + .Where(x => x.AcademicTermId == plan.AcademicTermId && + x.Status == TeachingTaskStatus.Published && + x.SchedulingMode == TeachingTaskSchedulingMode.Standard) + .Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name }) + .ToListAsync(cancellationToken); + var scheduled = await db.ScheduleEntries.AsNoTracking() + .Where(x => x.SchedulePlanId == planId) + .Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken); + var missing = tasks.Where(x => !scheduled.Contains(x.Id)) + .Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList(); + return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing }); + } + [HttpGet("auto-schedule-jobs/{jobId:guid}")] public async Task> GetAutomaticScheduleJob( diff --git a/versions.props b/versions.props index 092fb3d..41f5228 100644 --- a/versions.props +++ b/versions.props @@ -1,7 +1,7 @@ - 2.3.2 - 2.3.2 - 2.3.2 + 2.4.0-rc1 + 2.4.0-rc1 + 2.4.0-rc1 diff --git a/web/src/views/CourseGradeStatisticsView.vue b/web/src/views/CourseGradeStatisticsView.vue index 8aa7f46..afc2b7e 100644 --- a/web/src/views/CourseGradeStatisticsView.vue +++ b/web/src/views/CourseGradeStatisticsView.vue @@ -24,6 +24,14 @@ const rows = computed(() => [ const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0]) const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? []) +const recommendation = computed(() => { + const value = selectedRow.value?.value + if (!value) return '' + if (Number(value.passRate) < 70) return `合格率为 ${score(value.passRate)}%,建议优先复核低分段学生的平时与期末分项,并安排针对性答疑。` + if (Number(value.averageScore) < 70) return `平均分为 ${score(value.averageScore)},建议检查易失分知识点与教学进度,结合分数段安排补强。` + if (Number(value.standardDeviation) > 20) return `成绩离散度较高,建议关注不同教学班或学生群体的学习差异,核对评价标准与教学支持。` + return `平均分 ${score(value.averageScore)}、合格率 ${score(value.passRate)}%,当前表现稳定;可重点关注低分段学生的持续跟进。` +}) function score(value: unknown) { return Number(value).toFixed(1) @@ -210,6 +218,7 @@ onBeforeUnmount(() => { +
ACTION ADVICE

{{ recommendation }}

diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 0064dca..59b83b5 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -21,6 +21,7 @@ import { import http from '../api/http' import { useAuthStore } from '../stores/auth' import StudentDashboardView from './StudentDashboardView.vue' +import TeacherDashboardView from './TeacherDashboardView.vue' interface DashboardData { audience: { @@ -57,9 +58,24 @@ interface DashboardData { classroomReservations: number generalApprovals: number } + greeting: DashboardGreeting generatedAt: string } +interface DashboardGreeting { + role: string + title: string + subtitle: string + label: string + narrative: string + insights: Array<{ + label: string + value: string + hint: string + tone: 'calm' | 'positive' | 'attention' + }> +} + interface DashboardLink { key: string label: string @@ -68,24 +84,46 @@ interface DashboardLink { icon: Component } +interface WarningRecord { + id: string + studentName: string + studentNumber: string + className: string + status: number + detail: string +} + const router = useRouter() const auth = useAuthStore() const roles = computed(() => auth.user?.roles ?? []) const isStudentOverview = computed(() => roles.value.includes('Student') && !roles.value.some((role) => - ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role), + ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role), ), ) +const isTeacherOverview = computed(() => + roles.value.includes('Teacher') && + !roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role)), +) const loading = ref(true) const loadError = ref('') const data = ref(null) +const counselorWarnings = ref([]) const now = ref(new Date()) function hasRole(...allowedRoles: string[]) { return roles.value.some((role) => allowedRoles.includes(role)) } +const isCounselorDashboard = computed(() => + hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'), +) + +const activeCounselorWarnings = computed(() => + counselorWarnings.value.filter((warning) => warning.status === 1), +) + function parseDateOnly(value?: string) { if (!value) return null const [year, month, day] = value.slice(0, 10).split('-').map(Number) @@ -342,6 +380,19 @@ const readiness = computed(() => { ] }) +const operationAlerts = computed(() => { + const counts = data.value?.counts + if (!counts) return [] + const alerts = [] as Array<{ level: 'critical' | 'warning'; title: string; detail: string; route: string }> + const unpublished = counts.teachingTasks - counts.publishedTeachingTasks + const unscheduled = counts.publishedTeachingTasks - counts.scheduledTeachingTasks + if (unpublished > 0) alerts.push({ level: 'warning', title: '教学任务尚未发布', detail: `${unpublished} 个教学班尚未发布,后续排课与选课无法推进。`, route: '/teaching-tasks' }) + if (unscheduled > 0) alerts.push({ level: 'critical', title: '课表覆盖存在缺口', detail: `${unscheduled} 个已发布教学班尚未进入课表。`, route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable' }) + if (counts.submittedGradeSheets > 0) alerts.push({ level: 'warning', title: '成绩审核等待处理', detail: `${counts.submittedGradeSheets} 张成绩登记册已提交,等待审核或发布。`, route: '/grades' }) + if (counts.openCourseSelectionRounds > 0 && counts.courseEnrollments === 0) alerts.push({ level: 'warning', title: '开放选课尚无有效记录', detail: `${counts.openCourseSelectionRounds} 个选课批次开放中,但当前未发现有效选课。`, route: '/course-selections' }) + return alerts +}) + const primaryActionRoute = computed(() => todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications', ) @@ -351,6 +402,11 @@ async function loadDashboard() { loadError.value = '' try { data.value = (await http.get('/dashboard')).data + if (isCounselorDashboard.value) { + counselorWarnings.value = (await http.get('/warnings/records', { + params: { academicTermId: data.value.currentTerm?.id }, + })).data + } } catch { loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。' } finally { @@ -359,7 +415,7 @@ async function loadDashboard() { } onMounted(async () => { - if (isStudentOverview.value) { + if (isStudentOverview.value || isTeacherOverview.value) { loading.value = false return } @@ -369,6 +425,7 @@ onMounted(async () => {