Compare commits
Submodule
+1
Submodule Academic-Affairs-System.wiki added at c7be1438bc
@@ -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<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,
|
||||
@@ -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<DashboardGreetingInsight> Insights);
|
||||
|
||||
public sealed record DashboardGreetingInsight(
|
||||
string Label,
|
||||
string Value,
|
||||
string Hint,
|
||||
string Tone);
|
||||
|
||||
public sealed record DashboardAudience(
|
||||
string Level,
|
||||
string Title,
|
||||
|
||||
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
|
||||
ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/preflight")]
|
||||
public async Task<ActionResult> 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<ActionResult<AutomaticScheduleJobResponse>>
|
||||
GetAutomaticScheduleJob(
|
||||
|
||||
@@ -48,8 +48,9 @@ public sealed class OfficialDocumentTests
|
||||
|
||||
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code");
|
||||
|
||||
Assert.True(result.Content.Length > 5_000);
|
||||
Assert.True(result.Content.Length > 500, "生成的 PDF 不应为空。");
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
|
||||
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
|
||||
result.Sha256);
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<JiaowuBackendVersion>2.3.2</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.3.2</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.3.2</JiaowuSwaggerVersion>
|
||||
<JiaowuBackendVersion>2.4.0-rc1</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.4.0-rc1</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.4.0-rc1</JiaowuSwaggerVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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(() => {
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
|
||||
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
|
||||
|
||||
<section v-if="rows.length" class="chart-panel">
|
||||
|
||||
@@ -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<DashboardData | null>(null)
|
||||
const counselorWarnings = ref<WarningRecord[]>([])
|
||||
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<DashboardData>('/dashboard')).data
|
||||
if (isCounselorDashboard.value) {
|
||||
counselorWarnings.value = (await http.get<WarningRecord[]>('/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 () => {
|
||||
|
||||
<template>
|
||||
<StudentDashboardView v-if="isStudentOverview" />
|
||||
<TeacherDashboardView v-else-if="isTeacherOverview" />
|
||||
|
||||
<div v-else v-loading="loading" class="admin-dashboard">
|
||||
<el-result
|
||||
@@ -390,8 +447,8 @@ onMounted(async () => {
|
||||
<i>数据范围</i>
|
||||
</div>
|
||||
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p>
|
||||
<h1>{{ data.audience.title }}</h1>
|
||||
<p class="overview-description">{{ data.audience.description }}</p>
|
||||
<h1>{{ data.greeting.title }}</h1>
|
||||
<p class="overview-description">{{ data.greeting.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
|
||||
@@ -436,6 +493,23 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="greeting-insights" :aria-label="data.greeting.label">
|
||||
<span class="greeting-insights-label">{{ data.greeting.label }}</span>
|
||||
<div>
|
||||
<article
|
||||
v-for="insight in data.greeting.insights"
|
||||
:key="insight.label"
|
||||
:class="`greeting-insight ${insight.tone}`"
|
||||
>
|
||||
<span>{{ insight.label }}</span>
|
||||
<strong>{{ insight.value }}</strong>
|
||||
<small>{{ insight.hint }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="greeting-narrative">{{ data.greeting.narrative }}</p>
|
||||
|
||||
<section class="overview-metrics" aria-label="关键教学数据">
|
||||
<button
|
||||
v-for="metric in adminMetrics"
|
||||
@@ -450,7 +524,38 @@ onMounted(async () => {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="operation-alerts">
|
||||
<header><div><span>OPERATION WATCH</span><h2>运行预警</h2></div><small>{{ operationAlerts.length ? `${operationAlerts.length} 个运行断点需要关注` : '当前未发现运行断点' }}</small></header>
|
||||
<div v-if="operationAlerts.length" class="operation-alert-list">
|
||||
<button v-for="alert in operationAlerts" :key="alert.title" :class="alert.level" @click="router.push(alert.route)"><b>{{ alert.title }}</b><span>{{ alert.detail }}</span><i>立即处理 →</i></button>
|
||||
</div>
|
||||
<p v-else>教学任务、课表与成绩流程当前衔接正常。</p>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-work-grid">
|
||||
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-index">RADAR / COUNSELOR</span>
|
||||
<h2>需重点关注的学生</h2>
|
||||
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
|
||||
</div>
|
||||
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 →</button>
|
||||
</header>
|
||||
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
|
||||
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||
<span>{{ warning.className }}</span>
|
||||
<b>{{ warning.studentName }}</b>
|
||||
<small>{{ warning.detail }}</small>
|
||||
<i>查看 →</i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="todo-empty">
|
||||
<el-icon><Checked /></el-icon>
|
||||
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里。</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel todo-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
@@ -586,6 +691,62 @@ onMounted(async () => {
|
||||
background-size: 32px 32px, 32px 32px, auto;
|
||||
}
|
||||
|
||||
.greeting-insights {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
padding: 17px 21px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
.greeting-insights-label {
|
||||
align-self: center;
|
||||
color: var(--dashboard-teal);
|
||||
font: 700 10px/1.5 Consolas, monospace;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
.greeting-insights > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.greeting-insight {
|
||||
min-width: 0;
|
||||
padding-left: 13px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-left: 2px solid #a9bcc6;
|
||||
}
|
||||
|
||||
.greeting-insight.positive { border-color: var(--dashboard-teal); }
|
||||
.greeting-insight.attention { border-color: var(--dashboard-amber); }
|
||||
.greeting-insight span { color: #687788; font-size: 11px; }
|
||||
.greeting-insight strong { color: var(--dashboard-navy); font-size: 20px; }
|
||||
.greeting-insight small { overflow: hidden; color: #84909d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.greeting-narrative {
|
||||
margin: -7px 0 0;
|
||||
padding: 0 3px;
|
||||
color: #526078;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.operation-alerts { padding: 21px 24px; border: 1px solid #e5e8ed; background: #fff; }
|
||||
.operation-alerts header { display: flex; justify-content: space-between; gap: 16px; align-items: end; }
|
||||
.operation-alerts header span { color: var(--dashboard-teal); font: 700 10px/1 Consolas,monospace; letter-spacing: .12em; }
|
||||
.operation-alerts h2 { margin: 7px 0 0; color: var(--dashboard-navy); font-size: 19px; }
|
||||
.operation-alerts header small { color: #697789; font-size: 11px; }
|
||||
.operation-alert-list { margin-top: 16px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
|
||||
.operation-alert-list button { padding: 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #e8e1d3; border-left: 3px solid var(--dashboard-amber); background: #fffcf6; }
|
||||
.operation-alert-list button.critical { border-left-color: #ba5145; background: #fff9f8; }
|
||||
.operation-alert-list b { color: #334254; font-size: 13px; }.operation-alert-list span { color: #6d7888; font-size: 11px; line-height: 1.5; }.operation-alert-list i { color: #8b6d3a; font-size: 11px; font-style: normal; }
|
||||
.operation-alerts > p { margin: 15px 0 0; color: #627184; font-size: 12px; }
|
||||
|
||||
.overview-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -866,6 +1027,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 {
|
||||
@@ -1155,6 +1338,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
|
||||
.dashboard-work-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1170,6 +1354,13 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.operation-alerts { padding: 18px; }
|
||||
.operation-alert-list { grid-template-columns: 1fr; }
|
||||
.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 {
|
||||
|
||||
@@ -807,6 +807,16 @@ function changeEntryKind() {
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightSchedule() {
|
||||
try {
|
||||
const { data } = await http.get(`/schedules/plans/${selected.value.id}/preflight`)
|
||||
const message = data.unscheduledTasks
|
||||
? `尚有 ${data.unscheduledTasks} 个教学班未安排:\n${data.messages.join('\n')}`
|
||||
: '当前草稿已覆盖全部需要排课的教学班。'
|
||||
await ElMessageBox.alert(message, `排课前检查 · 已覆盖 ${data.scheduledTasks}/${data.totalTasks}`, { confirmButtonText: '知道了' })
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
if (!entryForm.teachingTaskId ||
|
||||
((entryForm.kind === 'Experiment' ||
|
||||
@@ -937,6 +947,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
复制调整
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
:disabled="scheduleJobLoading"
|
||||
@click="preflightSchedule"
|
||||
>
|
||||
排课前检查
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
type="warning"
|
||||
|
||||
@@ -70,6 +70,27 @@ interface GradeItem {
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
interface DashboardGreeting {
|
||||
title: string
|
||||
subtitle: string
|
||||
label: string
|
||||
narrative: string
|
||||
insights: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
tone: 'calm' | 'positive' | 'attention'
|
||||
}>
|
||||
}
|
||||
|
||||
interface WarningItem {
|
||||
id: string
|
||||
type: number
|
||||
status: number
|
||||
detail: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const exams = ref<ExamItem[]>([])
|
||||
const grades = ref<GradeItem[]>([])
|
||||
const dashboardGreeting = ref<DashboardGreeting | null>(null)
|
||||
const warnings = ref<WarningItem[]>([])
|
||||
const graduation = ref<any>(null)
|
||||
const now = ref(new Date())
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
@@ -159,6 +183,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)
|
||||
@@ -281,6 +310,9 @@ async function loadOverview() {
|
||||
http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
|
||||
http.get('/exams/my-schedule'),
|
||||
http.get('/grades/student/transcript'),
|
||||
http.get<DashboardGreeting>('/dashboard/greeting'),
|
||||
http.get<WarningItem[]>('/warnings/my-warnings'),
|
||||
http.get('/student/academic-planning'),
|
||||
])
|
||||
|
||||
if (results[0].status === 'fulfilled') {
|
||||
@@ -304,6 +336,15 @@ async function loadOverview() {
|
||||
} else {
|
||||
failedSections.value.push('考试成绩')
|
||||
}
|
||||
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('学业风险雷达')
|
||||
}
|
||||
if (results[6].status === 'fulfilled') graduation.value = results[6].value.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -315,8 +356,8 @@ onMounted(loadOverview)
|
||||
<section class="student-overview-hero">
|
||||
<div class="student-hero-copy">
|
||||
<span class="section-kicker">MY ACADEMIC DAY</span>
|
||||
<h2>{{ greeting }},{{ auth.user?.displayName ?? '同学' }}</h2>
|
||||
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
<h2>{{ dashboardGreeting?.title ?? `${greeting},${auth.user?.displayName ?? '同学'}` }}</h2>
|
||||
<p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
</div>
|
||||
<div class="today-status">
|
||||
<span>今日课程</span>
|
||||
@@ -325,6 +366,39 @@ onMounted(loadOverview)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="dashboardGreeting?.insights.length" class="student-greeting-insights" :aria-label="dashboardGreeting.label">
|
||||
<span>{{ dashboardGreeting.label }}</span>
|
||||
<article v-for="insight in dashboardGreeting.insights" :key="insight.label" :class="insight.tone">
|
||||
<small>{{ insight.label }}</small>
|
||||
<strong>{{ insight.value }}</strong>
|
||||
<em>{{ insight.hint }}</em>
|
||||
</article>
|
||||
</section>
|
||||
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
|
||||
|
||||
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
|
||||
<header>
|
||||
<div>
|
||||
<span class="panel-kicker">ACADEMIC RADAR</span>
|
||||
<h3>学业风险雷达</h3>
|
||||
</div>
|
||||
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
|
||||
</header>
|
||||
<p>{{ radarSummary }}</p>
|
||||
<div v-if="activeWarnings.length" class="risk-list">
|
||||
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||
<span>{{ categoryLabels.Warning }}</span>
|
||||
<strong>{{ warning.detail }}</strong>
|
||||
<i>去处理 →</i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="graduation?.baseline" class="graduation-nav">
|
||||
<div><span class="panel-kicker">GRADUATION NAVIGATION</span><h3>毕业导航</h3><p>已完成 {{ graduation.baseline.planCompletedCredits ?? 0 }} 学分,距离培养方案要求还差 {{ graduation.baseline.creditGap ?? 0 }} 学分。</p></div>
|
||||
<button type="button" @click="router.push('/academic-planning')">查看毕业航线 <el-icon><ArrowRight /></el-icon></button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="failedSections.length"
|
||||
type="warning"
|
||||
@@ -539,6 +613,63 @@ onMounted(loadOverview)
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.student-greeting-insights {
|
||||
padding: 15px 21px;
|
||||
display: grid;
|
||||
grid-template-columns: 105px repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
.student-greeting-insights > span {
|
||||
align-self: center;
|
||||
color: var(--teal);
|
||||
font: 700 10px/1.5 Consolas, monospace;
|
||||
letter-spacing: .11em;
|
||||
}
|
||||
|
||||
.student-greeting-insights article {
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-left: 2px solid #a9bcc6;
|
||||
}
|
||||
|
||||
.student-greeting-insights article.positive { border-color: var(--teal); }
|
||||
.student-greeting-insights article.attention { border-color: #c4812a; }
|
||||
.student-greeting-insights small { color: #667488; font-size: 10px; }
|
||||
.student-greeting-insights strong { color: var(--ink); font-size: 19px; }
|
||||
.student-greeting-insights em { overflow: hidden; color: var(--muted); font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.student-greeting-narrative {
|
||||
margin: -5px 0 0;
|
||||
padding: 0 3px;
|
||||
color: #526078;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.student-risk-radar {
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #fbfdfd;
|
||||
}
|
||||
.graduation-nav{padding:19px 22px;display:flex;justify-content:space-between;align-items:center;gap:18px;border:1px solid #dce6eb;background:#f7faf9}.graduation-nav h3{margin:7px 0;color:var(--ink);font-size:17px}.graduation-nav p{margin:0;color:#59677a;font-size:12px;line-height:1.6}.graduation-nav button{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--indigo);font-size:12px;white-space:nowrap}
|
||||
|
||||
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
|
||||
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
|
||||
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||
.student-risk-radar header button:hover { color: var(--indigo); }
|
||||
.student-risk-radar > 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;
|
||||
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
|
||||
.student-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
|
||||
.student-risk-radar { padding: 18px; }
|
||||
.graduation-nav{padding:18px;align-items:flex-start;flex-direction:column}
|
||||
.risk-list button { grid-template-columns: 1fr auto; }
|
||||
.risk-list span { grid-column: 1 / -1; }
|
||||
.student-overview {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ArrowRight, Calendar, DocumentChecked, Reading } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import http from '../api/http'
|
||||
|
||||
interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
|
||||
interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } }
|
||||
const router = useRouter()
|
||||
const greeting = ref<Greeting | null>(null)
|
||||
const sheets = ref<Sheet[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const statusLabel: Record<string, string> = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' }
|
||||
const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0
|
||||
const pendingSheets = computed(() => sheets.value.filter(x => x.sheet?.status === 'Draft' || x.sheet?.status === 'Returned'))
|
||||
async function load() {
|
||||
loading.value = true; failed.value = false
|
||||
try {
|
||||
const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard')
|
||||
greeting.value = dashboard.data.greeting
|
||||
const result = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } })
|
||||
sheets.value = result.data.items
|
||||
} catch { failed.value = true } finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="teacher-cockpit">
|
||||
<el-result v-if="failed" icon="warning" title="教学驾驶舱加载失败" sub-title="请检查服务连接后重新加载。"><template #extra><el-button type="primary" @click="load">重新加载</el-button></template></el-result>
|
||||
<template v-else-if="greeting">
|
||||
<section class="cockpit-hero">
|
||||
<span>TEACHING COCKPIT</span><h2>{{ greeting.title }}</h2><p>{{ greeting.subtitle }}</p><small>{{ greeting.narrative }}</small>
|
||||
</section>
|
||||
<section class="cockpit-metrics"><article v-for="item in greeting.insights" :key="item.label" :class="item.tone"><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.hint }}</small></article></section>
|
||||
<section class="cockpit-grid">
|
||||
<article class="cockpit-panel">
|
||||
<header><div><span><el-icon><DocumentChecked /></el-icon> GRADE PROGRESS</span><h3>成绩登记进度</h3></div><button @click="router.push('/grades')">成绩管理 <el-icon><ArrowRight /></el-icon></button></header>
|
||||
<div v-if="pendingSheets.length" class="sheet-list"><button v-for="item in pendingSheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><div><b>{{ item.courseName }}</b><small>{{ item.classNames.join('、') || item.taskName }} · {{ statusLabel[item.sheet?.status ?? ''] ?? '待登记' }}</small></div><strong>{{ progress(item.sheet) }}%</strong></button></div>
|
||||
<div v-else class="cockpit-empty"><el-icon><DocumentChecked /></el-icon><b>当前没有待提交成绩</b><span>成绩登记册会在这里按优先级显示。</span></div>
|
||||
</article>
|
||||
<article class="cockpit-panel">
|
||||
<header><div><span><el-icon><Reading /></el-icon> TEACHING CLASSES</span><h3>本学期教学班</h3></div><button @click="router.push('/teaching-tasks')">教学任务 <el-icon><ArrowRight /></el-icon></button></header>
|
||||
<div v-if="sheets.length" class="class-list"><button v-for="item in sheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><b>{{ item.courseName }}</b><span>{{ item.classNames.join('、') || item.taskName }}</span><small>{{ item.sheet ? `${item.sheet.studentCount} 人 · 已完成 ${item.sheet.completedCount} 人` : '尚未建立成绩登记册' }}</small></button></div>
|
||||
<div v-else class="cockpit-empty"><el-icon><Calendar /></el-icon><b>本学期暂未分配教学班</b><span>教学任务发布后会自动汇总到这里。</span></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.teacher-cockpit{display:grid;gap:18px}.cockpit-hero{padding:31px 35px;color:#fff;background:linear-gradient(120deg,#17284f,#244276 62%,#08726d);}.cockpit-hero>span,.cockpit-panel header>div>span{color:#71d9ca;font:700 10px/1 Consolas,monospace;letter-spacing:.12em}.cockpit-hero h2{margin:13px 0 8px;font:700 clamp(27px,3vw,38px)/1.2 "STZhongsong","Songti SC",serif}.cockpit-hero p{margin:0;color:#d1daf0}.cockpit-hero small{display:block;margin-top:15px;color:#aebee0;font-size:12px}.cockpit-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cockpit-metrics article{padding:17px;border-left:3px solid #a9bcc6;background:#f7faf9;display:grid;gap:4px}.cockpit-metrics .positive{border-color:#098174}.cockpit-metrics .attention{border-color:#ce8b2c}.cockpit-metrics span,.cockpit-metrics small{color:#657488;font-size:11px}.cockpit-metrics strong{color:#17284f;font-size:23px}.cockpit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.cockpit-panel{padding:23px 26px;border:1px solid #e2e7ec;background:#fff}.cockpit-panel header{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #e9edf0}.cockpit-panel h3{margin:8px 0 16px;color:#263648;font-size:18px}.cockpit-panel header button{border:0;background:transparent;color:#526078;font-size:12px;white-space:nowrap}.cockpit-panel button{cursor:pointer}.sheet-list button,.class-list button{width:100%;padding:14px 0;display:flex;justify-content:space-between;gap:12px;text-align:left;border:0;border-bottom:1px solid #edf0f3;background:transparent}.sheet-list b,.class-list b{display:block;color:#2e3c4c;font-size:14px}.sheet-list small,.class-list span,.class-list small{display:block;margin-top:5px;color:#748093;font-size:11px}.sheet-list strong{align-self:center;color:#0b8175;font:700 18px Consolas,monospace}.cockpit-empty{min-height:170px;display:grid;place-content:center;justify-items:center;color:#8c96a5;gap:8px;text-align:center;font-size:12px}.cockpit-empty .el-icon{font-size:26px}.cockpit-empty b{color:#5e6c7e}@media(max-width:760px){.cockpit-hero{padding:25px 21px}.cockpit-metrics,.cockpit-grid{grid-template-columns:1fr}.cockpit-panel{padding:20px 18px}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user