From 40064cedda8a1be7588f9cf7f4a7c6079c4bb2eb Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 10 Aug 2026 10:03:35 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E9=97=AE=E5=80=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/DashboardController.cs | 148 ++++++++++++++++++ web/src/views/DashboardView.vue | 73 ++++++++- web/src/views/StudentDashboardView.vue | 62 +++++++- 3 files changed, 279 insertions(+), 4 deletions(-) diff --git a/src/Jiaowu.Api/Controllers/DashboardController.cs b/src/Jiaowu.Api/Controllers/DashboardController.cs index 3478503..2fd036a 100644 --- a/src/Jiaowu.Api/Controllers/DashboardController.cs +++ b/src/Jiaowu.Api/Controllers/DashboardController.cs @@ -135,9 +135,143 @@ 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 enrollments = db.CourseEnrollments.AsNoTracking().Where(x => + x.StudentId == studentId.Value && + x.Status == CourseEnrollmentStatus.Enrolled && + currentTermId.HasValue && + x.CourseSelectionOffering!.CourseSelectionRound!.AcademicTermId == currentTermId.Value); + var selectedCourses = await enrollments.CountAsync(cancellationToken); + var selectedCredits = await enrollments.SumAsync( + x => (decimal?)x.CourseSelectionOffering!.TeachingTask!.Course!.Credits, + cancellationToken) ?? 0; + var publishedGrades = db.GradeRecords.AsNoTracking().Where(x => + x.StudentId == studentId.Value && + x.GradeSheet!.Status == GradeSheetStatus.Published); + 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} 门课程需要重点关注,建议优先查看课程反馈。" + : selectedCourses > 0 + ? $"本学期已选 {selectedCourses} 门课、{selectedCredits:0.#} 学分,保持现在的学习节奏。" + : "本学期暂未发现有效选课记录,可先查看选课安排。"; + return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", + [ + new DashboardGreetingInsight("本学期课程", $"{selectedCourses} 门", $"共 {selectedCredits: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} 学时。" + : "本学期暂未分配教学班,请留意教学任务安排。"; + return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", + [ + 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} 个教学班正在运行,当前没有积压待办。" + : "当前学期运行数据已就绪,可从教学任务开始推进。"; + return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", + [ + 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 +410,22 @@ 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, + 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/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index 0064dca..52654a5 100644 --- a/web/src/views/DashboardView.vue +++ b/web/src/views/DashboardView.vue @@ -57,9 +57,23 @@ interface DashboardData { classroomReservations: number generalApprovals: number } + greeting: DashboardGreeting generatedAt: string } +interface DashboardGreeting { + role: string + title: string + subtitle: string + label: string + insights: Array<{ + label: string + value: string + hint: string + tone: 'calm' | 'positive' | 'attention' + }> +} + interface DashboardLink { key: string label: string @@ -390,8 +404,8 @@ onMounted(async () => { 数据范围

ACADEMIC OPERATIONS

-

{{ data.audience.title }}

-

{{ data.audience.description }}

+

{{ data.greeting.title }}

+

{{ data.greeting.subtitle }}

+ +
+ +
+
+ +
当前没有重点关注学生新的学业预警会自动出现在这里。
+
+ +
@@ -943,6 +989,28 @@ onMounted(async () => { background: var(--dashboard-paper); } +.counselor-radar { border-top: 3px solid var(--dashboard-amber); } +.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; } +.radar-link:hover { color: var(--dashboard-blue); } +.counselor-risk-list { display: grid; } +.counselor-risk-list button { + padding: 13px 0; + display: grid; + grid-template-columns: 76px 68px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + text-align: left; + border: 0; + border-bottom: 1px solid #eee8dc; + background: transparent; +} +.counselor-risk-list button:last-child { border-bottom: 0; } +.counselor-risk-list button:hover b { color: var(--dashboard-blue); } +.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; } +.counselor-risk-list b { color: #3a4758; font-size: 13px; } +.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; } + .todo-panel, .quick-panel, .readiness-panel { @@ -1250,6 +1318,9 @@ button:focus-visible { @media (max-width: 760px) { .greeting-insights { padding: 15px; } .greeting-insights > div { grid-template-columns: 1fr; } + .counselor-risk-list button { grid-template-columns: 1fr auto; } + .counselor-risk-list span { grid-column: 1 / -1; } + .counselor-risk-list small { white-space: normal; } .admin-dashboard { gap: 10px; } .overview-hero { diff --git a/web/src/views/StudentDashboardView.vue b/web/src/views/StudentDashboardView.vue index 3e3e209..96a3ac0 100644 --- a/web/src/views/StudentDashboardView.vue +++ b/web/src/views/StudentDashboardView.vue @@ -83,6 +83,14 @@ interface DashboardGreeting { }> } +interface WarningItem { + id: string + type: number + status: number + detail: string + createdAt: string +} + const router = useRouter() const auth = useAuthStore() const loading = ref(true) @@ -93,6 +101,7 @@ const unreadCount = ref(0) const exams = ref([]) const grades = ref([]) const dashboardGreeting = ref(null) +const warnings = ref([]) const now = ref(new Date()) const categoryLabels: Record = { @@ -173,6 +182,11 @@ const recentGrades = computed(() => .slice(0, 4), ) +const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1)) +const radarSummary = computed(() => activeWarnings.value.length + ? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。` + : '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。') + function parseDateOnly(value?: string) { if (!value) return null const [year, month, day] = value.slice(0, 10).split('-').map(Number) @@ -296,6 +310,7 @@ async function loadOverview() { http.get('/exams/my-schedule'), http.get('/grades/student/transcript'), http.get('/dashboard/greeting'), + http.get('/warnings/my-warnings'), ]) if (results[0].status === 'fulfilled') { @@ -322,6 +337,11 @@ async function loadOverview() { if (results[4].status === 'fulfilled') { dashboardGreeting.value = results[4].value.data } + if (results[5].status === 'fulfilled') { + warnings.value = results[5].value.data + } else { + failedSections.value.push('学业风险雷达') + } loading.value = false } @@ -353,6 +373,24 @@ onMounted(loadOverview)

{{ dashboardGreeting.narrative }}

+
+
+
+ ACADEMIC RADAR +

学业风险雷达

+
+ +
+

{{ radarSummary }}

+
+ +
+
+ p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; } +.risk-list { margin-top: 13px; display: grid; } +.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; } +.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; } +.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; } +.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; } + .today-status { min-width: 180px; margin-left: auto; @@ -945,6 +1001,9 @@ onMounted(loadOverview) @media (max-width: 600px) { .student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; } + .student-risk-radar { padding: 18px; } + .risk-list button { grid-template-columns: 1fr auto; } + .risk-list span { grid-column: 1 / -1; } .student-overview { gap: 12px; } -- 2.47.3 From 9ffc5bf67c19aab9c85a297e053db2d39bf48e3a Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 10 Aug 2026 10:27:42 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E6=95=99=E5=B8=88=E6=95=99=E5=AD=A6?= =?UTF-8?q?=E9=A9=BE=E9=A9=B6=E8=88=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/views/DashboardView.vue | 8 +++- web/src/views/TeacherDashboardView.vue | 55 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 web/src/views/TeacherDashboardView.vue diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue index f2477bd..aa0a956 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: { @@ -101,6 +102,10 @@ const isStudentOverview = computed(() => ['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) @@ -397,7 +402,7 @@ async function loadDashboard() { } onMounted(async () => { - if (isStudentOverview.value) { + if (isStudentOverview.value || isTeacherOverview.value) { loading.value = false return } @@ -407,6 +412,7 @@ onMounted(async () => {