23 Commits
39 changed files with 9111 additions and 93 deletions
+3
View File
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
MYSQL_PASSWORD= MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD= MYSQL_ROOT_PASSWORD=
CLICKHOUSE_USER=jiaowu_analytics
CLICKHOUSE_PASSWORD=
RABBITMQ_USER=jiaowu RABBITMQ_USER=jiaowu
RABBITMQ_PASSWORD= RABBITMQ_PASSWORD=
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1 BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
+19
View File
@@ -2,6 +2,13 @@
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://0.0.0.0:8080 ASPNETCORE_URLS=http://0.0.0.0:8080
# 反向代理必须在转发请求时设置 X-Forwarded-For 和 X-Forwarded-Proto。
# 仅填写实际直接连接 API 的代理 IP;多个代理依次使用 __0、__1……。
# 使用 Docker 时通常是宿主机/代理容器在 Docker 网络中的 IP,而非访客 IP。
# 默认仅信任 127.0.0.1 和 ::1。
# ReverseProxy__TrustedProxies__0=127.0.0.1
# ReverseProxy__TrustedProxies__1=::1
Database__Provider=MySql Database__Provider=MySql
Database__ApplyMigrationsOnStartup=false Database__ApplyMigrationsOnStartup=false
Database__CommandTimeoutSeconds=30 Database__CommandTimeoutSeconds=30
@@ -55,6 +62,18 @@ PerformanceReporting__Enabled=false
PerformanceReporting__CacheSeconds=30 PerformanceReporting__CacheSeconds=30
PerformanceReporting__TimeoutSeconds=10 PerformanceReporting__TimeoutSeconds=10
# ClickHouse 仅作为异步分析读模型,不参与教务事务写入。启用前请为应用创建
# 仅能操作该分析库的独立账号,并通过 TLS 或受信任的内网访问。
ClickHouseAnalytics__Enabled=false
# ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
# ClickHouseAnalytics__Database=jiaowu_analytics
# ClickHouseAnalytics__UserName=jiaowu_analytics
# ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
ClickHouseAnalytics__CreateSchemaOnStartup=true
ClickHouseAnalytics__SyncIntervalSeconds=60
ClickHouseAnalytics__SourceLookbackDays=90
ClickHouseAnalytics__BatchSize=1000
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。 # 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
Operations__BackupDirectory=/var/lib/jiaowu/backups Operations__BackupDirectory=/var/lib/jiaowu/backups
Operations__BackupWarningHours=24 Operations__BackupWarningHours=24
Submodule Academic-Affairs-System.wiki added at fe88e71716
+16
View File
@@ -415,6 +415,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的 指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
`*MetricName``ServiceNameLabel` 配置项适配,无需改前端。 `*MetricName``ServiceNameLabel` 配置项适配,无需改前端。
### ClickHouse 分析读模型
ClickHouse 仅用于考勤、操作审计和成绩趋势的多维聚合,MySQL 仍是所有教务业务的唯一写入源。默认关闭;启用后,后台工作器以可重试的滚动窗口投影 MySQL 当前事实到 `ReplacingMergeTree` 表,重复投递不会改变读结果。
生产环境请为分析库创建独立账号,并限制其只能访问 `ClickHouseAnalytics__Database`。推荐通过 HTTPS 或内网连接:
```ini
ClickHouseAnalytics__Enabled=true
ClickHouseAnalytics__Endpoint=https://clickhouse.example.edu.cn:8443
ClickHouseAnalytics__Database=jiaowu_analytics
ClickHouseAnalytics__UserName=jiaowu_analytics
ClickHouseAnalytics__Password=REPLACE_WITH_A_STRONG_PASSWORD
```
分析概览通过 `GET /api/clickhouse-analytics/overview` 提供;学院管理员只能读取本学院的考勤和成绩趋势,跨学院的操作审计仅对全校数据范围角色开放。ClickHouse 暂时不可用时,业务写入不会失败,工作器会在下一个周期重试。
### 后台任务与 RabbitMQ ### 后台任务与 RabbitMQ
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与 自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
+27
View File
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
ConnectionStrings__Redis: "redis:6379,abortConnect=false" ConnectionStrings__Redis: "redis:6379,abortConnect=false"
Cache__Enabled: "true" Cache__Enabled: "true"
Cache__KeyPrefix: "jiaowu:v1" Cache__KeyPrefix: "jiaowu:v1"
ClickHouseAnalytics__Enabled: "true"
ClickHouseAnalytics__Endpoint: "http://clickhouse:8123"
ClickHouseAnalytics__Database: "jiaowu_analytics"
ClickHouseAnalytics__UserName: "${CLICKHOUSE_USER:-jiaowu_analytics}"
ClickHouseAnalytics__Password: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
BackgroundJobs__Transport: RabbitMq BackgroundJobs__Transport: RabbitMq
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}" BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}" BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
max-file: "3" max-file: "3"
services: services:
clickhouse:
image: clickhouse/clickhouse-server:25.8-alpine
restart: unless-stopped
environment:
CLICKHOUSE_DB: jiaowu_analytics
CLICKHOUSE_USER: "${CLICKHOUSE_USER:-jiaowu_analytics}"
CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:?请在 .env.docker 中设置 CLICKHOUSE_PASSWORD}"
healthcheck:
test:
- CMD-SHELL
- wget -qO- http://localhost:8123/ping | grep -q Ok
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
- clickhouse-data:/var/lib/clickhouse
logging: *json-logging
rabbitmq: rabbitmq:
image: rabbitmq:4.2-management-alpine image: rabbitmq:4.2-management-alpine
restart: unless-stopped restart: unless-stopped
@@ -135,6 +159,8 @@ services:
condition: service_started condition: service_started
mysql: mysql:
condition: service_healthy condition: service_healthy
clickhouse:
condition: service_healthy
migrate: migrate:
condition: service_completed_successfully condition: service_completed_successfully
ports: ports:
@@ -165,5 +191,6 @@ services:
volumes: volumes:
mysql-data: mysql-data:
clickhouse-data:
rabbitmq-data: rabbitmq-data:
backup-data: backup-data:
@@ -0,0 +1,88 @@
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Analytics;
using Jiaowu.Api.Infrastructure.Auth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AnalyticsUsers)]
[Route("api/clickhouse-analytics")]
public sealed class ClickHouseAnalyticsController(
ClickHouseAnalyticsClient client,
ClickHouseAnalyticsOptions options,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string AnalyticsUsers =
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," + SystemRoles.Leader;
[HttpGet("status")]
public ActionResult GetStatus() => Ok(new
{
options.Enabled,
options.SyncIntervalSeconds,
options.SourceLookbackDays,
options.BatchSize,
options.Database
});
[HttpGet("overview")]
public async Task<ActionResult> GetOverview(
DateOnly? from,
DateOnly? to,
CancellationToken cancellationToken)
{
if (!options.Enabled)
return Conflict(new ProblemDetails
{
Title = "ClickHouse 分析未启用",
Detail = "请先配置 ClickHouseAnalytics 并启动分析库。",
Status = StatusCodes.Status409Conflict
});
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
var start = from ?? end.AddDays(-29);
if (start > end || end.DayNumber - start.DayNumber > 366)
return ValidationProblem("分析时间范围应为 1 到 366 天,且开始日期不能晚于结束日期。");
var scope = currentUserDataScope.Current;
var collegeFilter = scope.RestrictedCollegeId is { } collegeId
? $" AND collegeId = toUUID('{collegeId:D}')"
: string.Empty;
var dateFilter = $"attendanceDate >= toDate('{start:yyyy-MM-dd}') AND attendanceDate <= toDate('{end:yyyy-MM-dd}')";
var attendance = await client.QueryAsync($"""
SELECT attendanceDate, count() AS total, countIf(status = 1) AS present,
countIf(status = 2) AS absent, countIf(status = 3) AS late
FROM {options.Database}.attendanceRecords FINAL
WHERE {dateFilter}{collegeFilter}
GROUP BY attendanceDate ORDER BY attendanceDate
""", cancellationToken);
var grades = await client.QueryAsync($"""
SELECT academicTermId, any(academicTermName) AS academicTermName,
sum(studentCount) AS studentCount,
round(sum(averageScore * studentCount) / nullIf(sum(studentCount), 0), 2) AS averageScore,
round(sum(passedCount) / nullIf(sum(studentCount), 0), 4) AS passRate
FROM {options.Database}.gradeStatistics FINAL
WHERE 1 = 1{collegeFilter}
GROUP BY academicTermId ORDER BY academicTermName
""", cancellationToken);
// Audit data has no college dimension, so it is never exposed to a
// college-scoped administrator.
var audit = scope.RestrictedCollegeId is null
? await client.QueryAsync($"""
SELECT toDate(occurredAt) AS date, count() AS total,
countIf(statusCode >= 400) AS failed
FROM {options.Database}.auditEvents FINAL
WHERE occurredAt >= toDateTime('{start:yyyy-MM-dd}')
AND occurredAt < toDateTime('{end.AddDays(1):yyyy-MM-dd}')
GROUP BY date ORDER BY date
""", cancellationToken)
: [];
return Ok(new { Start = start, End = end, Attendance = attendance, Grades = grades, Audit = audit });
}
}
@@ -135,9 +135,179 @@ public sealed class DashboardController(
currentTerm, currentTerm,
counts, counts,
pending, pending,
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
DateTime.UtcNow)); 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( private async Task<DashboardPending> LoadPendingAsync(
CurrentUserScope scope, CurrentUserScope scope,
Guid? restrictedCollegeId, Guid? restrictedCollegeId,
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
DashboardTerm? CurrentTerm, DashboardTerm? CurrentTerm,
DashboardCounts Counts, DashboardCounts Counts,
DashboardPending Pending, DashboardPending Pending,
DashboardGreeting Greeting,
DateTime GeneratedAt); 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( public sealed record DashboardAudience(
string Level, string Level,
string Title, string Title,
@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data; using System.Data;
using System.Globalization;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
@@ -74,19 +76,36 @@ public sealed class ExperimentGradesController(
item.Teacher!.TeacherNumber.Contains(keyword) || item.Teacher!.TeacherNumber.Contains(keyword) ||
item.Teacher.Name.Contains(keyword))); item.Teacher.Name.Contains(keyword)));
var total = await source.CountAsync(cancellationToken); var total = await source.Select(x => x.TeachingTaskId)
var items = await source .Distinct()
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate) .CountAsync(cancellationToken);
.ThenBy(x => x.TeachingTask!.TaskNumber) var taskIds = await source
.ThenBy(x => x.Code) .GroupBy(x => new
{
x.TeachingTaskId,
StartDate = x.TeachingTask!.AcademicTerm!.StartDate,
x.TeachingTask.TaskNumber
})
.OrderByDescending(x => x.Key.StartDate)
.ThenBy(x => x.Key.TaskNumber)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => x.Key.TeachingTaskId)
.ToListAsync(cancellationToken);
var projects = await source
.Where(x => taskIds.Contains(x.TeachingTaskId))
.OrderBy(x => x.ScheduleWeek ?? int.MaxValue)
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.DayOfWeek)
.ThenBy(x => x.ScheduleEntry == null ? int.MaxValue : x.ScheduleEntry.StartPeriod)
.ThenBy(x => x.StartDate)
.ThenBy(x => x.Code)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
x.Code, x.Code,
x.Name, x.Name,
x.ArrangementMode, x.ArrangementMode,
x.ScheduleWeek,
ProjectStatus = x.Status, ProjectStatus = x.Status,
x.TeachingTaskId, x.TeachingTaskId,
x.TeachingTask!.TaskNumber, x.TeachingTask!.TaskNumber,
@@ -99,6 +118,14 @@ public sealed class ExperimentGradesController(
TeacherNames = x.TeachingTask.Teachers TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary) .OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name), .Select(item => item.Teacher!.Name),
ScheduleEntry = x.ScheduleEntry == null
? null
: new
{
x.ScheduleEntry.DayOfWeek,
x.ScheduleEntry.StartPeriod,
x.ScheduleEntry.PeriodCount
},
Sheet = x.GradeSheet == null Sheet = x.GradeSheet == null
? null ? null
: new : new
@@ -122,6 +149,32 @@ public sealed class ExperimentGradesController(
} }
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var order = taskIds.Select((id, index) => new { id, index })
.ToDictionary(x => x.id, x => x.index);
var items = projects.GroupBy(x => x.TeachingTaskId)
.OrderBy(group => order[group.Key])
.Select(group =>
{
var first = group.First();
return new
{
TeachingTaskId = group.Key,
first.TaskNumber,
first.TaskName,
first.AcademicTermId,
first.TermName,
first.CourseCode,
first.CourseName,
first.CollegeName,
first.TeacherNames,
ProjectCount = group.Count(),
SheetCount = group.Count(project => project.Sheet != null),
ScoredCount = group.Sum(project => project.Sheet?.ScoredCount ?? 0),
StudentCount = group.Sum(project => project.Sheet?.StudentCount ?? 0),
Projects = group.ToList()
};
})
.ToList();
return Ok(new return Ok(new
{ {
Items = items, Items = items,
@@ -316,6 +369,15 @@ public sealed class ExperimentGradesController(
ProjectCode = x.ExperimentProject!.Code, ProjectCode = x.ExperimentProject!.Code,
ProjectName = x.ExperimentProject.Name, ProjectName = x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode, x.ExperimentProject.ArrangementMode,
x.ExperimentProject.ScheduleWeek,
ScheduleEntry = x.ExperimentProject.ScheduleEntry == null
? null
: new
{
x.ExperimentProject.ScheduleEntry.DayOfWeek,
x.ExperimentProject.ScheduleEntry.StartPeriod,
x.ExperimentProject.ScheduleEntry.PeriodCount
},
x.ExperimentProject.TeachingTaskId, x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber, x.ExperimentProject.TeachingTask!.TaskNumber,
TaskName = x.ExperimentProject.TeachingTask.Name, TaskName = x.ExperimentProject.TeachingTask.Name,
@@ -574,6 +636,166 @@ public sealed class ExperimentGradesController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> DownloadTemplate(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
var headers = ExperimentImportHeaders(sheet.Items);
var rows = sheet.Records
.OrderBy(record => record.Student!.StudentNumber)
.Select(record =>
{
var values = new List<object?>
{
record.Student!.StudentNumber,
record.Student.Name,
record.Student.AdministrativeClass!.Name,
ParticipationLabel(record.ParticipationStatus)
};
foreach (var item in sheet.Items.OrderBy(item => item.SortOrder))
values.Add(record.ItemScores.FirstOrDefault(score =>
score.ExperimentGradeItemId == item.Id)?.Score);
values.Add(record.SafetyViolation);
values.Add(record.AttemptNumber);
values.Add(null);
values.Add(record.TeacherComment);
return (IReadOnlyList<object?>)values;
})
.ToList();
var itemCount = sheet.Items.Count;
var totalColumn = 7 + itemCount;
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
var instructions = new List<string>
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
"评分项填写 0—100 的数值,留空表示暂未录入。",
"安全违规填写 是 或 否;实验次数填写 1—20。",
"实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。",
$"本实验共 {itemCount} 个评分项:{string.Join("", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。"
};
var bytes = ExcelWorkbookHelper.Create(
"实验成绩导入", headers, rows, instructions,
(worksheet, rowNumber) =>
{
var itemReferences = Enumerable.Range(5, itemCount)
.Select(column => $"{ColumnLetter(column)}{rowNumber}")
.ToArray();
var weightedExpression = string.Join("+", sheet.Items
.OrderBy(item => item.SortOrder)
.Select((item, index) =>
$"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
var statusReference = $"D{rowNumber}";
var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}";
worksheet.Cell(rowNumber, totalColumn).FormulaA1 =
$"=IF(OR({statusReference}=\"\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))";
var totalCell = worksheet.Cell(rowNumber, totalColumn);
totalCell.Style.NumberFormat.Format = "0.0";
totalCell.Style.Font.Bold = true;
totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
foreach (var column in scoreColumns)
{
var format = worksheet.Range(rowNumber, column, rowNumber, column)
.AddConditionalFormat().WhenLessThan(60);
format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
return File(bytes, ExcelWorkbookHelper.ContentType,
$"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx");
}
[HttpPost("sheets/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> ImportRecords(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (ExperimentGradeSheetStatus.Draft or ExperimentGradeSheetStatus.Returned))
return ConflictProblem("只有录入中或已退回实验成绩单可以导入成绩。");
var headers = ExperimentImportHeaders(sheet.Items);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(file,
headers.Where(header => header != "实验总评(自动计算)").ToArray(),
cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。");
var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber,
StringComparer.OrdinalIgnoreCase);
var items = sheet.Items.OrderBy(item => item.SortOrder).ToList();
var errors = new List<string>();
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"];
if (string.IsNullOrWhiteSpace(studentNumber))
{
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
continue;
}
if (!records.TryGetValue(studentNumber, out var record))
{
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。");
continue;
}
if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError))
{
errors.Add($"第 {row.RowNumber} 行:{participationError}");
continue;
}
if (!TryParseYesNo(row["安全违规"], out var safetyViolation))
{
errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。");
continue;
}
if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20)
{
errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。");
continue;
}
var scores = new List<decimal?>();
foreach (var item in items)
{
var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break;
scores.Add(score);
}
if (scores.Count != items.Count) continue;
record.ParticipationStatus = participationStatus;
record.SafetyViolation = safetyViolation;
record.AttemptNumber = attemptNumber;
record.TeacherComment = Normalize(row["教师评语"]);
var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId);
for (var index = 0; index < items.Count; index++)
scoreMap[items[index].Id].Score = scores[index];
Recalculate(sheet, record);
updated++;
}
if (errors.Count > 0) return ImportValidationProblem(errors);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { Updated = updated, Total = rows.Count });
}
[HttpPost("sheets/{id:guid}/sync-participants")] [HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants( public async Task<ActionResult> SyncParticipants(
@@ -834,6 +1056,22 @@ public sealed class ExperimentGradesController(
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
} }
private async Task<ExperimentGradeSheet?> LoadEditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync( private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project, ExperimentProject project,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -974,6 +1212,93 @@ public sealed class ExperimentGradesController(
private static bool ValidScore(decimal? score) => private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100; !score.HasValue || score.Value is >= 0 and <= 100;
private static List<string> ExperimentImportHeaders(
IEnumerable<ExperimentGradeItem> items)
{
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name));
headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]);
return headers;
}
private static string ParticipationLabel(
ExperimentParticipationStatus status) => status switch
{
ExperimentParticipationStatus.Pending => "待登记",
ExperimentParticipationStatus.Completed => "已完成",
ExperimentParticipationStatus.Absent => "缺席",
ExperimentParticipationStatus.Excused => "请假",
ExperimentParticipationStatus.Makeup => "补做",
ExperimentParticipationStatus.Exempt => "免做",
_ => "待登记"
};
private static bool TryParseParticipationStatus(
ExcelRow row,
out ExperimentParticipationStatus status,
out string error)
{
error = string.Empty;
switch (row["参与状态"])
{
case "待登记": status = ExperimentParticipationStatus.Pending; return true;
case "已完成": status = ExperimentParticipationStatus.Completed; return true;
case "缺席": status = ExperimentParticipationStatus.Absent; return true;
case "请假": status = ExperimentParticipationStatus.Excused; return true;
case "补做": status = ExperimentParticipationStatus.Makeup; return true;
case "免做": status = ExperimentParticipationStatus.Exempt; return true;
default:
status = default;
error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。";
return false;
}
}
private static bool TryParseYesNo(string value, out bool result)
{
if (value == "是") { result = true; return true; }
if (value == "否") { result = false; return true; }
result = false;
return false;
}
private static decimal? ParseOptionalDecimal(
ExcelRow row,
string header,
decimal minimum,
decimal maximum,
List<string> errors)
{
var value = row[header];
if (string.IsNullOrWhiteSpace(value)) return null;
if (decimal.TryParse(value, NumberStyles.Number,
CultureInfo.InvariantCulture, out var result) &&
result >= minimum && result <= maximum)
return result;
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
return null;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static string ColumnLetter(int column)
{
var result = string.Empty;
while (column > 0)
{
column--;
result = (char)('A' + column % 26) + result;
column /= 26;
}
return result;
}
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -666,6 +666,39 @@ public sealed class ExperimentsController(
return NoContent(); return NoContent();
} }
[HttpPut("{id:guid}/published-details")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CorrectPublishedProjectDetails(
Guid id,
PublishedExperimentProjectCorrectionRequest request,
CancellationToken cancellationToken)
{
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (project is null) return NotFound();
if (project.Status != ExperimentProjectStatus.Published)
return ConflictProblem("只有已发布实验项目可以修正教学内容。");
if (!CanCorrectPublishedProject(project.TeachingTask!)) return Forbid();
if (string.IsNullOrWhiteSpace(request.Name))
return ValidationProblem("请填写实验项目名称。");
var changed = project.Name != request.Name.Trim() ||
project.Description != Normalize(request.Description) ||
project.Requirements != Normalize(request.Requirements);
project.Name = request.Name.Trim();
project.Description = Normalize(request.Description);
project.Requirements = Normalize(request.Requirements);
await db.SaveChangesAsync(cancellationToken);
if (changed)
await NotifyProjectCorrectionAsync(project, cancellationToken);
return NoContent();
}
[HttpPut("batch/names")] [HttpPut("batch/names")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProjectNames( public async Task<ActionResult> UpdateProjectNames(
@@ -1442,6 +1475,16 @@ public sealed class ExperimentsController(
taskIds.Contains(x.TeachingTaskId)); taskIds.Contains(x.TeachingTaskId));
} }
private bool CanCorrectPublishedProject(TeachingTask task)
{
var scope = currentUserDataScope.Current;
return scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
task.Teachers.Any(item =>
item.Teacher?.UserId == scope.UserId);
}
private Task<Student?> CurrentStudentAsync( private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x => db.Students.FirstOrDefaultAsync(x =>
@@ -1492,6 +1535,22 @@ public sealed class ExperimentsController(
NotificationCategory.Schedule); NotificationCategory.Schedule);
} }
private async Task NotifyProjectCorrectionAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
var userIds = await RosterUserIdsAsync(project.TeachingTaskId, cancellationToken);
if (userIds.Count == 0) return;
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验项目内容已更新",
$"《{project.TeachingTask!.Course!.Name}》的“{project.Name}”教学内容或要求已修正,请重新查看。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds) private static List<Guid>? ValidateBulkProjectIds(IReadOnlyList<Guid>? projectIds)
{ {
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList(); var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
@@ -1569,6 +1628,11 @@ public sealed record ExperimentProjectRequest(
DateOnly EndDate, DateOnly EndDate,
Guid? ScheduleEntryId = null); Guid? ScheduleEntryId = null);
public sealed record PublishedExperimentProjectCorrectionRequest(
[Required, MaxLength(120)] string Name,
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements);
public sealed record ExperimentProjectBatchRequest( public sealed record ExperimentProjectBatchRequest(
[Required] IReadOnlyList<Guid> TeachingTaskIds, [Required] IReadOnlyList<Guid> TeachingTaskIds,
[Required, MaxLength(40)] string Code, [Required, MaxLength(40)] string Code,
@@ -706,7 +706,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]); headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
var rows = sheet.Records.Select(record => var rows = sheet.Records.Select(record =>
{ {
@@ -723,8 +723,8 @@ public sealed class GradesController(
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score; .FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score); values.Add(score);
} }
values.Add(null);
values.Add(record.FinalScore); values.Add(record.FinalScore);
values.Add(null);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" : values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" : record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" : record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
@@ -745,9 +745,9 @@ public sealed class GradesController(
var regularColumn = 4; var regularColumn = 4;
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray(); var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
var totalColumn = regularColumn + itemColumns.Length + 1; var finalColumn = regularColumn + itemColumns.Length + 1;
var finalColumn = totalColumn + 1; var totalColumn = finalColumn + 1;
var statusColumn = finalColumn + 1; var statusColumn = totalColumn + 1;
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) }; var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
weightedColumns.AddRange(sheet.Items.Select((item, index) => weightedColumns.AddRange(sheet.Items.Select((item, index) =>
(itemColumns[index], item.Weight))); (itemColumns[index], item.Weight)));
@@ -819,7 +819,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]); headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows; IReadOnlyList<ExcelRow> rows;
try try
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
var total = await source.CountAsync(cancellationToken); var total = await source.CountAsync(cancellationToken);
var items = await source var items = await source
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.ThenByDescending(x => x.Id)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.Select(x => new .Select(x => new
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
ToResponse(job)); 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}")] [HttpGet("auto-schedule-jobs/{jobId:guid}")]
public async Task<ActionResult<AutomaticScheduleJobResponse>> public async Task<ActionResult<AutomaticScheduleJobResponse>>
GetAutomaticScheduleJob( GetAutomaticScheduleJob(
@@ -0,0 +1,122 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace Jiaowu.Api.Infrastructure.Analytics;
public sealed class ClickHouseAnalyticsClient(
HttpClient httpClient,
ClickHouseAnalyticsOptions options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public bool IsEnabled => options.Enabled;
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
await ExecuteAsync($"CREATE DATABASE IF NOT EXISTS {options.Database}", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.auditEvents
(
id UUID,
occurredAt DateTime64(3, 'UTC'),
userId Nullable(UUID),
method LowCardinality(String),
path String,
statusCode UInt16,
ipAddress Nullable(String),
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(occurredAt)
ORDER BY (id)
""", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.attendanceRecords
(
attendanceSheetId UUID,
studentId UUID,
attendanceDate Date,
teachingTaskId UUID,
academicTermId UUID,
collegeId UUID,
status UInt8,
checkInAt Nullable(DateTime64(3, 'UTC')),
checkedInMethod Nullable(UInt8),
appealStatus UInt8,
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(attendanceDate)
ORDER BY (attendanceSheetId, studentId)
""", cancellationToken);
await ExecuteAsync($"""
CREATE TABLE IF NOT EXISTS {options.Database}.gradeStatistics
(
gradeSheetId UUID,
teachingTaskId UUID,
courseId UUID,
academicTermId UUID,
collegeId UUID,
academicTermName LowCardinality(String),
studentCount UInt32,
passedCount UInt32,
excellentCount UInt32,
averageScore Decimal(8, 2),
passRate Decimal(8, 4),
excellentRate Decimal(8, 4),
calculatedAt DateTime64(3, 'UTC'),
projectedAt DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(projectedAt)
PARTITION BY toYYYYMM(calculatedAt)
ORDER BY (gradeSheetId)
""", cancellationToken);
}
public async Task InsertAsync<T>(string table, IReadOnlyCollection<T> rows, CancellationToken cancellationToken)
{
if (rows.Count == 0) return;
var payload = JsonSerializer.Serialize(rows, JsonOptions);
await ExecuteAsync(
$"INSERT INTO {options.Database}.{table} FORMAT JSONEachRow\n{ToJsonLines(payload)}",
cancellationToken);
}
public async Task<JsonElement[]> QueryAsync(string sql, CancellationToken cancellationToken)
{
using var response = await SendAsync(sql + " FORMAT JSON", cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"ClickHouse 查询失败 ({(int)response.StatusCode}){content}");
using var document = JsonDocument.Parse(content);
return document.RootElement.GetProperty("data")
.EnumerateArray().Select(x => x.Clone()).ToArray();
}
private async Task ExecuteAsync(string sql, CancellationToken cancellationToken)
{
using var response = await SendAsync(sql, cancellationToken);
if (response.IsSuccessStatusCode) return;
var content = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HttpRequestException($"ClickHouse 写入失败 ({(int)response.StatusCode}){content}");
}
private Task<HttpResponseMessage> SendAsync(string sql, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage(HttpMethod.Post, "")
{
Content = new StringContent(sql, Encoding.UTF8, "text/plain")
};
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(
$"{options.UserName}:{options.Password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
return httpClient.SendAsync(request, cancellationToken);
}
private static string ToJsonLines(string json)
{
using var document = JsonDocument.Parse(json);
return string.Join('\n', document.RootElement.EnumerateArray().Select(x => x.GetRawText()));
}
}
@@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
namespace Jiaowu.Api.Infrastructure.Analytics;
public sealed partial class ClickHouseAnalyticsOptions
{
public const string SectionName = "ClickHouseAnalytics";
public bool Enabled { get; set; }
public string Endpoint { get; set; } = "http://localhost:8123";
public string Database { get; set; } = "jiaowu_analytics";
public string UserName { get; set; } = "jiaowu_analytics";
public string Password { get; set; } = "";
public bool CreateSchemaOnStartup { get; set; } = true;
public int SyncIntervalSeconds { get; set; } = 60;
public int SourceLookbackDays { get; set; } = 90;
public int BatchSize { get; set; } = 1000;
[GeneratedRegex("^[a-zA-Z_][a-zA-Z0-9_]{0,62}$")]
private static partial Regex IdentifierPattern();
public bool HasValidIdentifiers() =>
IdentifierPattern().IsMatch(Database);
}
@@ -0,0 +1,123 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Analytics;
/// <summary>
/// Projects MySQL facts to ClickHouse. The projection is deliberately
/// best-effort: business writes never depend on an analytics database.
/// ReplacingMergeTree plus FINAL reads make repeated lookback batches safe.
/// </summary>
public sealed class ClickHouseAnalyticsProjectionWorker(
IServiceScopeFactory scopeFactory,
ClickHouseAnalyticsClient client,
ClickHouseAnalyticsOptions options,
ILogger<ClickHouseAnalyticsProjectionWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Enabled) return;
try
{
if (options.CreateSchemaOnStartup)
await client.EnsureSchemaAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(exception, "ClickHouse 分析表初始化失败,将在下一轮重试。");
}
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(options.SyncIntervalSeconds));
do
{
try
{
await ProjectAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(exception, "ClickHouse 分析投影失败,将在下一轮重试。");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task ProjectAsync(CancellationToken cancellationToken)
{
var projectedAt = DateTime.UtcNow;
var from = projectedAt.AddDays(-options.SourceLookbackDays);
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var auditCount = await ProjectAuditsAsync(db, from, projectedAt, cancellationToken);
var attendanceCount = await ProjectAttendanceAsync(db, from.Date, projectedAt, cancellationToken);
var gradeCount = await ProjectGradesAsync(db, projectedAt, cancellationToken);
logger.LogInformation(
"ClickHouse 分析投影完成:审计 {AuditCount},考勤 {AttendanceCount},成绩 {GradeCount}。",
auditCount, attendanceCount, gradeCount);
}
private async Task<int> ProjectAuditsAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorAt = from;
var cursorId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.AuditLogs.AsNoTracking()
.Where(x => x.CreatedAt > cursorAt || x.CreatedAt == cursorAt && x.Id.CompareTo(cursorId) > 0)
.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id).Take(options.BatchSize)
.Select(x => new { x.Id, OccurredAt = x.CreatedAt, x.UserId, x.Method, x.Path, x.StatusCode, x.IpAddress, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("auditEvents", rows, cancellationToken);
count += rows.Count;
cursorAt = rows[^1].OccurredAt;
cursorId = rows[^1].Id;
}
}
private async Task<int> ProjectAttendanceAsync(AppDbContext db, DateTime from, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorDate = from;
var cursorSheetId = Guid.Empty;
var cursorStudentId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.AttendanceRecords.AsNoTracking()
.Where(x => x.AttendanceSheet!.AttendanceDate > cursorDate ||
x.AttendanceSheet.AttendanceDate == cursorDate &&
(x.AttendanceSheetId.CompareTo(cursorSheetId) > 0 ||
x.AttendanceSheetId == cursorSheetId && x.StudentId.CompareTo(cursorStudentId) > 0))
.OrderBy(x => x.AttendanceSheet!.AttendanceDate).ThenBy(x => x.AttendanceSheetId).ThenBy(x => x.StudentId).Take(options.BatchSize)
.Select(x => new { x.AttendanceSheetId, x.StudentId, AttendanceDate = x.AttendanceSheet!.AttendanceDate, TeachingTaskId = x.AttendanceSheet.TeachingTaskId, AcademicTermId = x.AttendanceSheet.TeachingTask!.AcademicTermId, CollegeId = x.AttendanceSheet.TeachingTask.Course!.CollegeId, Status = (byte)x.Status, x.CheckInAt, CheckedInMethod = x.CheckedInMethod == null ? null : (byte?)x.CheckedInMethod, AppealStatus = (byte)x.AppealStatus, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("attendanceRecords", rows, cancellationToken);
count += rows.Count;
cursorDate = rows[^1].AttendanceDate;
cursorSheetId = rows[^1].AttendanceSheetId;
cursorStudentId = rows[^1].StudentId;
}
}
private async Task<int> ProjectGradesAsync(AppDbContext db, DateTime projectedAt, CancellationToken cancellationToken)
{
var cursorAt = DateTime.MinValue;
var cursorSheetId = Guid.Empty;
var count = 0;
while (true)
{
var rows = await db.TeachingTaskGradeStatistics.AsNoTracking()
.Where(x => x.CalculatedAt > cursorAt || x.CalculatedAt == cursorAt && x.GradeSheetId.CompareTo(cursorSheetId) > 0)
.OrderBy(x => x.CalculatedAt).ThenBy(x => x.GradeSheetId).Take(options.BatchSize)
.Select(x => new { x.GradeSheetId, x.TeachingTaskId, x.CourseId, x.AcademicTermId, CollegeId = x.TeachingTask!.Course!.CollegeId, AcademicTermName = x.TeachingTask.AcademicTerm!.Name, x.StudentCount, x.PassedCount, x.ExcellentCount, x.AverageScore, x.PassRate, x.ExcellentRate, x.CalculatedAt, ProjectedAt = projectedAt })
.ToListAsync(cancellationToken);
if (rows.Count == 0) return count;
await client.InsertAsync("gradeStatistics", rows, cancellationToken);
count += rows.Count;
cursorAt = rows[^1].CalculatedAt;
cursorSheetId = rows[^1].GradeSheetId;
}
}
}
@@ -166,6 +166,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
configurationBuilder.Properties<TimeOnly>() configurationBuilder.Properties<TimeOnly>()
.HaveConversion<TimeOnlyTimeSpanConverter>() .HaveConversion<TimeOnlyTimeSpanConverter>()
.HaveColumnType("time"); .HaveColumnType("time");
// MySQL DATETIME has no offset or DateTimeKind. All system timestamps
// are persisted as UTC, so restore that contract when materializing
// them. System.Text.Json will then emit the trailing "Z", allowing
// browsers to convert timestamps to the viewer's local time correctly.
configurationBuilder.Properties<DateTime>()
.HaveConversion<UtcDateTimeConverter>()
.HaveColumnType("datetime(6)");
} }
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
@@ -1086,6 +1094,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
builder.Entity<ExamSession>(entity => builder.Entity<ExamSession>(entity =>
{ {
// Exam slot times are China-local wall-clock times, not instants.
// Keep their existing API representation offset-free.
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
@@ -1112,6 +1124,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ExamRoomAssignment>(entity => builder.Entity<ExamRoomAssignment>(entity =>
{ {
entity.ToTable("ExamRooms"); entity.ToTable("ExamRooms");
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt }) entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
.HasDatabaseName("IX_ExamRooms_Plan_Time"); .HasDatabaseName("IX_ExamRooms_Plan_Time");
entity.HasIndex(x => new entity.HasIndex(x => new
@@ -1200,6 +1214,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
}); });
builder.Entity<MakeupExamSession>(entity => builder.Entity<MakeupExamSession>(entity =>
{ {
// Makeup-exam slot times follow the same wall-clock convention.
entity.Property(x => x.StartsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.EndsAt).HasConversion<UnspecifiedDateTimeConverter>();
entity.Property(x => x.Notes).HasMaxLength(500); entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt }); entity.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
entity.HasIndex(x => x.TeachingTaskId); entity.HasIndex(x => x.TeachingTaskId);
@@ -1475,8 +1492,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Title).HasMaxLength(200); entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasColumnType("longtext"); entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.LinkUrl).HasMaxLength(300); entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead }); // Each inbox query starts with its recipient. Keep the selected
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt }); // sort fields in the index so large inboxes do not need a filesort.
entity.HasIndex(x => new { x.UserId, x.CreatedAt, x.Id });
entity.HasIndex(x => new { x.UserId, x.IsRead, x.CreatedAt, x.Id });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt, x.Id });
entity.HasIndex(x => x.MessageDispatchId); entity.HasIndex(x => x.MessageDispatchId);
entity.HasIndex(x => x.CreatedAt); entity.HasIndex(x => x.CreatedAt);
entity.HasOne(x => x.MessageDispatch) entity.HasOne(x => x.MessageDispatch)
@@ -1643,3 +1663,15 @@ public sealed class TimeOnlyTimeSpanConverter()
: ValueConverter<TimeOnly, TimeSpan>( : ValueConverter<TimeOnly, TimeSpan>(
time => time.ToTimeSpan(), time => time.ToTimeSpan(),
value => TimeOnly.FromTimeSpan(value)); value => TimeOnly.FromTimeSpan(value));
public sealed class UtcDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
value => value.Kind == DateTimeKind.Local
? value.ToUniversalTime()
: DateTime.SpecifyKind(value, DateTimeKind.Utc),
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));
public sealed class UnspecifiedDateTimeConverter()
: ValueConverter<DateTime, DateTime>(
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified),
value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified));
@@ -102,6 +102,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260809_53_course_grade_statistics_refresh_settings"; "20260809_53_course_grade_statistics_refresh_settings";
private const string ExperimentCourseGradesMigration = private const string ExperimentCourseGradesMigration =
"20260809_54_experiment_course_grades"; "20260809_54_experiment_course_grades";
private const string NotificationInboxIndexesMigration =
"20260810_55_notification_inbox_indexes";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -731,6 +733,10 @@ public sealed class DevelopmentSqliteMigrator(
ExperimentCourseGradesMigration, ExperimentCourseGradesMigration,
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements, experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
cancellationToken); cancellationToken);
await ApplyMigrationAsync(
NotificationInboxIndexesMigration,
NotificationInboxIndexesStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -2999,6 +3005,15 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");""" """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CourseGradeStatisticsRefreshSettings_Key" ON "CourseGradeStatisticsRefreshSettings" ("Key");"""
]; ];
private static readonly string[] NotificationInboxIndexesStatements =
[
"""DROP INDEX "IX_Notifications_UserId_IsRead";""",
"""DROP INDEX "IX_Notifications_UserId_Category_CreatedAt";""",
"""CREATE INDEX "IX_Notifications_UserId_CreatedAt_Id" ON "Notifications" ("UserId", "CreatedAt", "Id");""",
"""CREATE INDEX "IX_Notifications_UserId_IsRead_CreatedAt_Id" ON "Notifications" ("UserId", "IsRead", "CreatedAt", "Id");""",
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt_Id" ON "Notifications" ("UserId", "Category", "CreatedAt", "Id");"""
];
private static readonly string[] ExperimentCourseGradesStatements = private static readonly string[] ExperimentCourseGradesStatements =
[ [
""" """
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OptimizeNotificationInboxIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_IsRead",
table: "Notifications");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_Category_CreatedAt_Id",
table: "Notifications",
columns: new[] { "UserId", "Category", "CreatedAt", "Id" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_CreatedAt_Id",
table: "Notifications",
columns: new[] { "UserId", "CreatedAt", "Id" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_IsRead_CreatedAt_Id",
table: "Notifications",
columns: new[] { "UserId", "IsRead", "CreatedAt", "Id" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_Category_CreatedAt_Id",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_CreatedAt_Id",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_IsRead_CreatedAt_Id",
table: "Notifications");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications",
columns: new[] { "UserId", "Category", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_IsRead",
table: "Notifications",
columns: new[] { "UserId", "IsRead" });
}
}
}
@@ -3417,9 +3417,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("MessageDispatchId"); b.HasIndex("MessageDispatchId");
b.HasIndex("UserId", "IsRead"); b.HasIndex("UserId", "CreatedAt", "Id");
b.HasIndex("UserId", "Category", "CreatedAt"); b.HasIndex("UserId", "Category", "CreatedAt", "Id");
b.HasIndex("UserId", "IsRead", "CreatedAt", "Id");
b.ToTable("Notifications"); b.ToTable("Notifications");
}); });
+54
View File
@@ -1,4 +1,5 @@
using System.Text; using System.Text;
using System.Net;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System; using Jiaowu.Api.Domain.System;
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Configuration;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Analytics;
using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Middleware; using Jiaowu.Api.Infrastructure.Middleware;
@@ -19,6 +21,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -101,12 +104,30 @@ var observabilityOptions = builder.Configuration
var performanceReportingOptions = builder.Configuration var performanceReportingOptions = builder.Configuration
.GetSection(PerformanceReportingOptions.SectionName) .GetSection(PerformanceReportingOptions.SectionName)
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions(); .Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
var clickHouseAnalyticsOptions = builder.Configuration
.GetSection(ClickHouseAnalyticsOptions.SectionName)
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
var rabbitMqOptions = builder.Configuration var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName) .GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions(); .Get<RabbitMqOptions>() ?? new RabbitMqOptions();
var ssoOptions = builder.Configuration var ssoOptions = builder.Configuration
.GetSection(SsoOptions.SectionName) .GetSection(SsoOptions.SectionName)
.Get<SsoOptions>() ?? new SsoOptions(); .Get<SsoOptions>() ?? new SsoOptions();
var trustedProxyAddresses = builder.Configuration
.GetSection("ReverseProxy:TrustedProxies")
.Get<string[]>() ?? [];
var trustedProxies = trustedProxyAddresses
.Select(value =>
{
if (!IPAddress.TryParse(value, out var address))
{
throw new InvalidOperationException(
$"ReverseProxy:TrustedProxies contains an invalid IP address: '{value}'.");
}
return address;
})
.ToArray();
if (ssoOptions.Enabled && if (ssoOptions.Enabled &&
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) || (string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
@@ -252,6 +273,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
{ {
throw new InvalidOperationException("RabbitMq 连接配置不完整。"); throw new InvalidOperationException("RabbitMq 连接配置不完整。");
} }
if (clickHouseAnalyticsOptions.Enabled &&
(!Uri.TryCreate(clickHouseAnalyticsOptions.Endpoint, UriKind.Absolute, out var clickHouseEndpoint) ||
clickHouseEndpoint.Scheme is not ("http" or "https") ||
!clickHouseAnalyticsOptions.HasValidIdentifiers() ||
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.UserName) ||
string.IsNullOrWhiteSpace(clickHouseAnalyticsOptions.Password) ||
clickHouseAnalyticsOptions.SyncIntervalSeconds is < 10 or > 86400 ||
clickHouseAnalyticsOptions.SourceLookbackDays is < 1 or > 3650 ||
clickHouseAnalyticsOptions.BatchSize is < 1 or > 10000))
{
throw new InvalidOperationException("ClickHouseAnalytics 配置无效。");
}
if (backgroundJobOptions.UsesRabbitMq && if (backgroundJobOptions.UsesRabbitMq &&
!builder.Environment.IsDevelopment() && !builder.Environment.IsDevelopment() &&
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) || (rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
@@ -282,6 +316,7 @@ builder.Services.AddSingleton(backgroundJobOptions);
builder.Services.AddSingleton(operationsOptions); builder.Services.AddSingleton(operationsOptions);
builder.Services.AddSingleton(observabilityOptions); builder.Services.AddSingleton(observabilityOptions);
builder.Services.AddSingleton(performanceReportingOptions); builder.Services.AddSingleton(performanceReportingOptions);
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
builder.Services.AddSingleton(rabbitMqOptions); builder.Services.AddSingleton(rabbitMqOptions);
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>(); builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
builder.Services.AddMemoryCache(); builder.Services.AddMemoryCache();
@@ -290,6 +325,11 @@ builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
var reporting = services.GetRequiredService<PerformanceReportingOptions>(); var reporting = services.GetRequiredService<PerformanceReportingOptions>();
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds); client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
}); });
builder.Services.AddHttpClient<ClickHouseAnalyticsClient>((_, client) =>
{
client.BaseAddress = new Uri(clickHouseAnalyticsOptions.Endpoint.TrimEnd('/') + "/");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.Configure<OfficialDocumentOptions>( builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>((services, options) => builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
@@ -441,6 +481,7 @@ builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>(); builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>(); builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
builder.Services.AddSingleton<BackgroundJobTelemetry>(); builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>(); builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>(); builder.Services.AddScoped<OperationalHealthService>();
@@ -530,6 +571,18 @@ if (ssoOptions.Enabled)
}); });
} }
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 1;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
foreach (var proxy in trustedProxies)
{
options.KnownProxies.Add(proxy);
}
});
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
@@ -644,6 +697,7 @@ builder.Services.AddSwaggerGen(options =>
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders();
app.UseExceptionHandler(); app.UseExceptionHandler();
app.UseResponseCompression(); app.UseResponseCompression();
app.Use(async (context, next) => app.Use(async (context, next) =>
+17
View File
@@ -39,6 +39,17 @@
"SlowDatabaseMetric": "jiaowu_db_command_slow_total", "SlowDatabaseMetric": "jiaowu_db_command_slow_total",
"FailedDatabaseMetric": "jiaowu_db_command_failed_total" "FailedDatabaseMetric": "jiaowu_db_command_failed_total"
}, },
"ClickHouseAnalytics": {
"Enabled": false,
"Endpoint": "http://localhost:8123",
"Database": "jiaowu_analytics",
"UserName": "jiaowu_analytics",
"Password": "",
"CreateSchemaOnStartup": true,
"SyncIntervalSeconds": 60,
"SourceLookbackDays": 90,
"BatchSize": 1000
},
"Operations": { "Operations": {
"BackupDirectory": "data/backups", "BackupDirectory": "data/backups",
"BackupWarningHours": 24, "BackupWarningHours": 24,
@@ -94,6 +105,12 @@
"FrontendBaseUrl": "", "FrontendBaseUrl": "",
"CallbackUrl": "" "CallbackUrl": ""
}, },
"ReverseProxy": {
"TrustedProxies": [
"127.0.0.1",
"::1"
]
},
"Cors": { "Cors": {
"Origins": [ "Origins": [
"capacitor://localhost", "capacitor://localhost",
@@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using ClosedXML.Excel;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests
CancellationToken.None)); CancellationToken.None));
} }
[Fact]
public async Task Teacher_CanDownloadAndImportExperimentGradeTemplate()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-EXCEL",
Name = "Excel 实验成绩",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.ExperimentProjects.Add(project);
await fixture.Db.SaveChangesAsync();
var admin = fixture.ExperimentGrades(fixture.AdminScope);
await admin.CreateSheet(new ExperimentGradeSheetRequest(
project.Id, 1, 60,
[new ExperimentGradeItemRequest("操作", ExperimentGradeItemKind.Operation, 100)]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
fixture.Db.ChangeTracker.Clear();
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
var template = Assert.IsType<FileContentResult>(await teacher.DownloadTemplate(
sheet.Id, CancellationToken.None));
Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
template.ContentType);
await using var templateStream = new MemoryStream(template.FileContents);
await using var stream = new MemoryStream();
using (var workbook = new XLWorkbook(templateStream))
{
var worksheet = workbook.Worksheet("实验成绩导入");
worksheet.Cell("D2").Value = "已完成";
worksheet.Cell("E2").Value = 85;
workbook.SaveAs(stream);
}
stream.Position = 0;
var file = new FormFile(stream, 0, stream.Length, "file", "实验成绩导入模板.xlsx");
Assert.IsType<OkObjectResult>(await teacher.ImportRecords(
sheet.Id, file, CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
Assert.Equal(85m, await fixture.Db.ExperimentGradeRecords
.Select(record => record.TotalScore)
.SingleAsync());
}
[Fact] [Fact]
public async Task ManagementList_IsPagedFilteredAndCollegeScoped() public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
{ {
@@ -146,10 +198,11 @@ public sealed class ExperimentGradesControllerTests
1, 1,
10, 10,
CancellationToken.None)); CancellationToken.None));
Assert.Equal(12, Property<int>(page.Value, "Total")); Assert.Equal(1, Property<int>(page.Value, "Total"));
Assert.Equal(10, Property<System.Collections.IEnumerable>( var course = Assert.Single(Property<System.Collections.IEnumerable>(
page.Value, page.Value, "Items").Cast<object>());
"Items").Cast<object>().Count()); Assert.Equal(12, Property<System.Collections.IEnumerable>(
course, "Projects").Cast<object>().Count());
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement( var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
null, null,
@@ -162,6 +215,89 @@ public sealed class ExperimentGradesControllerTests
Assert.Equal(1, Property<int>(filtered.Value, "Total")); Assert.Equal(1, Property<int>(filtered.Value, "Total"));
} }
[Fact]
public async Task ManagementList_OrdersProjectsByScheduledWeekAndPeriod()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var plan = new SchedulePlan
{
AcademicTermId = fixture.Term.Id,
Name = "实验课表",
Version = "v1",
Status = SchedulePlanStatus.Published
};
var laterEntry = new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.Classroom.Id,
Kind = ScheduleEntryKind.Experiment,
DayOfWeek = 4,
StartPeriod = 5,
PeriodCount = 2,
StartWeek = 5,
EndWeek = 5,
WeekPattern = WeekPattern.All
};
var earlierEntry = new ScheduleEntry
{
SchedulePlanId = plan.Id,
TeachingTaskId = fixture.Task.Id,
ClassroomId = fixture.Classroom.Id,
Kind = ScheduleEntryKind.Experiment,
DayOfWeek = 2,
StartPeriod = 3,
PeriodCount = 2,
StartWeek = 2,
EndWeek = 2,
WeekPattern = WeekPattern.All
};
fixture.Db.AddRange(plan, laterEntry, earlierEntry);
fixture.Db.AddRange(
new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
ScheduleEntryId = laterEntry.Id,
ScheduleWeek = 5,
Code = "LAB-A",
Name = "后面的实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published
},
new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
ScheduleEntryId = earlierEntry.Id,
ScheduleWeek = 2,
Code = "LAB-Z",
Name = "前面的实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published
});
await fixture.Db.SaveChangesAsync();
fixture.Db.ChangeTracker.Clear();
var result = Assert.IsType<OkObjectResult>(await fixture.ExperimentGrades(
fixture.AdminScope).GetManagement(
fixture.Term.Id, null, null, null, 1, 10, CancellationToken.None));
var course = Assert.Single(Property<System.Collections.IEnumerable>(
result.Value, "Items").Cast<object>());
var projects = Property<System.Collections.IEnumerable>(course, "Projects")
.Cast<object>()
.ToList();
Assert.Equal(["前面的实验", "后面的实验"],
projects.Select(project => Property<string>(project, "Name")));
var schedule = Property<object>(projects[0], "ScheduleEntry");
Assert.Equal(2, Property<int>(projects[0], "ScheduleWeek"));
Assert.Equal(2, Property<int>(schedule, "DayOfWeek"));
Assert.Equal(3, Property<int>(schedule, "StartPeriod"));
}
private static T Property<T>(object? value, string name) => private static T Property<T>(object? value, string name) =>
Assert.IsAssignableFrom<T>( Assert.IsAssignableFrom<T>(
value!.GetType().GetProperty(name)!.GetValue(value)); value!.GetType().GetProperty(name)!.GetValue(value));
@@ -246,6 +246,35 @@ public sealed class ExperimentsControllerTests
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync()); Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
} }
[Fact]
public async Task PublishedProject_AllowsTeachingDetailCorrectionWithoutChangingSchedule()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProject(
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
CancellationToken.None);
var project = await fixture.Db.ExperimentProjects.SingleAsync();
await controller.PublishProject(project.Id, CancellationToken.None);
fixture.Db.ChangeTracker.Clear();
Assert.IsType<NoContentResult>(await controller.CorrectPublishedProjectDetails(
project.Id,
new PublishedExperimentProjectCorrectionRequest(
"修正后的实验名称",
"按本班教学计划调整实验步骤。",
"请提前完成环境检查。"),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var corrected = await fixture.Db.ExperimentProjects.SingleAsync();
Assert.Equal("修正后的实验名称", corrected.Name);
Assert.Equal("按本班教学计划调整实验步骤。", corrected.Description);
Assert.Equal("请提前完成环境检查。", corrected.Requirements);
Assert.Equal("LAB-C", corrected.Code);
Assert.Equal(fixture.ScheduleEntry.Id, corrected.ScheduleEntryId);
}
[Fact] [Fact]
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged() public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
{ {
@@ -1,3 +1,4 @@
using ClosedXML.Excel;
using Jiaowu.Api.Contracts; using Jiaowu.Api.Contracts;
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
var sheet = new GradeSheet var sheet = new GradeSheet
{ {
TeachingTaskId = tasks[0].Id, TeachingTaskId = tasks[0].Id,
RegularWeight = 30, RegularWeight = 20,
FinalWeight = 70, FinalWeight = 60,
Items =
[
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
],
Records = students.Select(student => new GradeRecord Records = students.Select(student => new GradeRecord
{ {
StudentId = student.Id StudentId = student.Id
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")! var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
.GetValue(secondDetail.Value)!; .GetValue(secondDetail.Value)!;
Assert.Single(ReadItems(secondSheet, "Records")); Assert.Single(ReadItems(secondSheet, "Records"));
var templateResult = Assert.IsType<FileContentResult>(
await controller.DownloadTemplate(sheet.Id, CancellationToken.None));
using var stream = new MemoryStream(templateResult.FileContents);
using var workbook = new XLWorkbook(stream);
var worksheet = workbook.Worksheet("成绩导入");
Assert.Equal(
["学号", "姓名", "班级", "平时成绩", "实验", "期末成绩", "总分(自动计算)", "考试状态", "备注"],
worksheet.Row(1).CellsUsed().Select(cell => cell.GetString()));
Assert.Contains("D2*20/100", worksheet.Cell("G2").FormulaA1);
Assert.Contains("E2*20/100", worksheet.Cell("G2").FormulaA1);
Assert.Contains("F2*60/100", worksheet.Cell("G2").FormulaA1);
} }
private static int ReadInt(object value, string property) => private static int ReadInt(object value, string property) =>
@@ -234,6 +234,53 @@ public sealed class NotificationsControllerTests
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId); Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
} }
[Fact]
public async Task Mark_all_read_only_updates_the_current_users_unread_notifications()
{
await using var fixture = await NotificationFixture.CreateAsync();
fixture.Db.Notifications.AddRange(
new Notification
{
UserId = fixture.Sender.Id,
Title = "当前用户未读",
Content = "应被标为已读。"
},
new Notification
{
UserId = fixture.Sender.Id,
Title = "当前用户已读",
Content = "应保持已读。",
IsRead = true
},
new Notification
{
UserId = fixture.CollegeRecipient.Id,
Title = "其他用户未读",
Content = "不应被修改。"
});
await fixture.Db.SaveChangesAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var result = await controller.MarkAllRead(CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var notifications = await fixture.Db.Notifications.AsNoTracking()
.OrderBy(x => x.Title)
.ToListAsync();
Assert.All(
notifications.Where(x => x.UserId == fixture.Sender.Id),
notification => Assert.True(notification.IsRead));
Assert.False(Assert.Single(
notifications,
x => x.UserId == fixture.CollegeRecipient.Id).IsRead);
}
[Fact] [Fact]
public async Task Publishing_course_grades_automatically_notifies_roster_students() public async Task Publishing_course_grades_automatically_notifies_roster_students()
{ {
@@ -48,8 +48,9 @@ public sealed class OfficialDocumentTests
var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code"); 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.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
Assert.Equal( Assert.Equal(
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(), Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
result.Sha256); result.Sha256);
+3 -3
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<JiaowuBackendVersion>2.3.2</JiaowuBackendVersion> <JiaowuBackendVersion>2.4.0-rc2</JiaowuBackendVersion>
<JiaowuFrontendVersion>2.3.2</JiaowuFrontendVersion> <JiaowuFrontendVersion>2.4.0-rc2</JiaowuFrontendVersion>
<JiaowuSwaggerVersion>2.3.2</JiaowuSwaggerVersion> <JiaowuSwaggerVersion>2.4.0-rc2</JiaowuSwaggerVersion>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+4
View File
@@ -194,6 +194,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']), hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' }, { path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
), ),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader']),
{ path: '/event-analytics', label: '运行数据分析' },
),
], ],
}, },
{ {
+8
View File
@@ -263,6 +263,14 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'], roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher'],
}, },
}, },
{
path: 'event-analytics',
name: 'event-analytics',
component: () => import('../views/EventAnalyticsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'],
},
},
{ {
path: 'other-exams', path: 'other-exams',
name: 'other-exams', name: 'other-exams',
@@ -24,6 +24,14 @@ const rows = computed(() => [
const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0]) const selectedRow = computed(() => rows.value.find(item => item.key === activeScope.value) ?? rows.value[0])
const selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? []) 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) { function score(value: unknown) {
return Number(value).toFixed(1) return Number(value).toFixed(1)
@@ -210,6 +218,7 @@ onBeforeUnmount(() => {
</dl> </dl>
</article> </article>
</section> </section>
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" /> <el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
<section v-if="rows.length" class="chart-panel"> <section v-if="rows.length" class="chart-panel">
+195 -4
View File
@@ -21,6 +21,7 @@ import {
import http from '../api/http' import http from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import StudentDashboardView from './StudentDashboardView.vue' import StudentDashboardView from './StudentDashboardView.vue'
import TeacherDashboardView from './TeacherDashboardView.vue'
interface DashboardData { interface DashboardData {
audience: { audience: {
@@ -57,9 +58,24 @@ interface DashboardData {
classroomReservations: number classroomReservations: number
generalApprovals: number generalApprovals: number
} }
greeting: DashboardGreeting
generatedAt: string 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 { interface DashboardLink {
key: string key: string
label: string label: string
@@ -68,24 +84,46 @@ interface DashboardLink {
icon: Component icon: Component
} }
interface WarningRecord {
id: string
studentName: string
studentNumber: string
className: string
status: number
detail: string
}
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const roles = computed(() => auth.user?.roles ?? []) const roles = computed(() => auth.user?.roles ?? [])
const isStudentOverview = computed(() => const isStudentOverview = computed(() =>
roles.value.includes('Student') && roles.value.includes('Student') &&
!roles.value.some((role) => !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 loading = ref(true)
const loadError = ref('') const loadError = ref('')
const data = ref<DashboardData | null>(null) const data = ref<DashboardData | null>(null)
const counselorWarnings = ref<WarningRecord[]>([])
const now = ref(new Date()) const now = ref(new Date())
function hasRole(...allowedRoles: string[]) { function hasRole(...allowedRoles: string[]) {
return roles.value.some((role) => allowedRoles.includes(role)) 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) { function parseDateOnly(value?: string) {
if (!value) return null if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number) 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(() => const primaryActionRoute = computed(() =>
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications', todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
) )
@@ -351,6 +402,11 @@ async function loadDashboard() {
loadError.value = '' loadError.value = ''
try { try {
data.value = (await http.get<DashboardData>('/dashboard')).data 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 { } catch {
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。' loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
} finally { } finally {
@@ -359,7 +415,7 @@ async function loadDashboard() {
} }
onMounted(async () => { onMounted(async () => {
if (isStudentOverview.value) { if (isStudentOverview.value || isTeacherOverview.value) {
loading.value = false loading.value = false
return return
} }
@@ -369,6 +425,7 @@ onMounted(async () => {
<template> <template>
<StudentDashboardView v-if="isStudentOverview" /> <StudentDashboardView v-if="isStudentOverview" />
<TeacherDashboardView v-else-if="isTeacherOverview" />
<div v-else v-loading="loading" class="admin-dashboard"> <div v-else v-loading="loading" class="admin-dashboard">
<el-result <el-result
@@ -390,8 +447,8 @@ onMounted(async () => {
<i>数据范围</i> <i>数据范围</i>
</div> </div>
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p> <p class="overview-eyebrow">ACADEMIC OPERATIONS</p>
<h1>{{ data.audience.title }}</h1> <h1>{{ data.greeting.title }}</h1>
<p class="overview-description">{{ data.audience.description }}</p> <p class="overview-description">{{ data.greeting.subtitle }}</p>
</div> </div>
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)"> <button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
@@ -436,6 +493,23 @@ onMounted(async () => {
</div> </div>
</section> </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="关键教学数据"> <section class="overview-metrics" aria-label="关键教学数据">
<button <button
v-for="metric in adminMetrics" v-for="metric in adminMetrics"
@@ -450,7 +524,38 @@ onMounted(async () => {
</button> </button>
</section> </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"> <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"> <article class="dashboard-panel todo-panel">
<header class="panel-heading"> <header class="panel-heading">
<div> <div>
@@ -586,6 +691,62 @@ onMounted(async () => {
background-size: 32px 32px, 32px 32px, auto; 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 { .overview-hero::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -866,6 +1027,28 @@ onMounted(async () => {
background: var(--dashboard-paper); 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, .todo-panel,
.quick-panel, .quick-panel,
.readiness-panel { .readiness-panel {
@@ -1155,6 +1338,7 @@ button:focus-visible {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
.dashboard-work-grid { .dashboard-work-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1170,6 +1354,13 @@ button:focus-visible {
} }
@media (max-width: 760px) { @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; } .admin-dashboard { gap: 10px; }
.overview-hero { .overview-hero {
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
interface Status {
enabled: boolean
syncIntervalSeconds: number
sourceLookbackDays: number
batchSize: number
database: string
}
const status = ref<Status>()
const attendance = ref<any[]>([])
const grades = ref<any[]>([])
const audit = ref<any[]>([])
const loading = ref(false)
const range = ref<[Date, Date]>([
new Date(Date.now() - 29 * 24 * 60 * 60 * 1000),
new Date(),
])
const attendanceTotals = computed(() => attendance.value.reduce((total, row) => total + Number(row.total ?? 0), 0))
const absentTotals = computed(() => attendance.value.reduce((total, row) => total + Number(row.absent ?? 0), 0))
const auditTotals = computed(() => audit.value.reduce((total, row) => total + Number(row.total ?? 0), 0))
function dateOnly(value: Date) {
const offset = value.getTimezoneOffset() * 60_000
return new Date(value.getTime() - offset).toISOString().slice(0, 10)
}
async function load() {
loading.value = true
try {
const [statusResponse, overviewResponse] = await Promise.all([
http.get<Status>('/clickhouse-analytics/status'),
http.get('/clickhouse-analytics/overview', {
params: { from: dateOnly(range.value[0]), to: dateOnly(range.value[1]) },
}),
])
status.value = statusResponse.data
attendance.value = overviewResponse.data.attendance ?? []
grades.value = overviewResponse.data.grades ?? []
audit.value = overviewResponse.data.audit ?? []
} catch (error) {
ElMessage.error(apiErrorMessage(error) || '加载运行数据分析失败。')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<main v-loading="loading" class="event-analytics page-stack">
<section class="page-intro">
<div>
<span class="eyebrow">CLICKHOUSE READ MODEL</span>
<h2>运行数据分析</h2>
<p>考勤教学班成绩与访问审计的只读聚合不会影响教务业务写入</p>
</div>
<div class="actions">
<el-date-picker v-model="range" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" />
<el-button type="primary" :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<el-alert v-if="status && !status.enabled" type="warning" :closable="false" show-icon title="ClickHouse 分析未启用">
请在服务配置中启用 ClickHouseAnalytics 后再查看分析数据
</el-alert>
<template v-else>
<section class="metrics">
<article><el-icon><DataAnalysis /></el-icon><span>考勤记录</span><b>{{ attendanceTotals.toLocaleString() }}</b></article>
<article><el-icon><DataAnalysis /></el-icon><span>缺勤记录</span><b>{{ absentTotals.toLocaleString() }}</b></article>
<article v-if="audit.length"><el-icon><DataAnalysis /></el-icon><span>访问审计</span><b>{{ auditTotals.toLocaleString() }}</b></article>
</section>
<section class="analysis-grid">
<el-card shadow="never">
<template #header>每日考勤</template>
<el-table :data="attendance" size="small" empty-text="所选范围暂无考勤投影数据">
<el-table-column prop="attendanceDate" label="日期" min-width="110" />
<el-table-column prop="total" label="总人次" align="right" />
<el-table-column prop="present" label="到课" align="right" />
<el-table-column prop="absent" label="缺勤" align="right" />
<el-table-column prop="late" label="迟到" align="right" />
</el-table>
</el-card>
<el-card shadow="never">
<template #header>学期成绩趋势</template>
<el-table :data="grades" size="small" empty-text="暂无已计算的教学班成绩统计">
<el-table-column prop="academicTermName" label="学期" min-width="130" />
<el-table-column prop="studentCount" label="学生数" align="right" />
<el-table-column prop="averageScore" label="加权平均分" align="right" />
<el-table-column prop="passRate" label="通过率" align="right">
<template #default="{ row }">{{ (Number(row.passRate) * 100).toFixed(1) }}%</template>
</el-table-column>
</el-table>
</el-card>
</section>
<el-card v-if="audit.length" shadow="never">
<template #header>访问审计仅全校数据范围</template>
<el-table :data="audit" size="small">
<el-table-column prop="date" label="日期" min-width="110" />
<el-table-column prop="total" label="操作量" align="right" />
<el-table-column prop="failed" label="异常响应" align="right" />
</el-table>
</el-card>
</template>
</main>
</template>
<style scoped>
.event-analytics { --ink: #18324f; --line: #dce3ec; }
.page-intro { display: flex; justify-content: space-between; align-items: end; gap: 18px; }
.eyebrow { color: #3d75aa; font-size: 12px; letter-spacing: .12em; font-weight: 700; }
h2 { margin: 5px 0; color: var(--ink); } p { margin: 0; color: #6b7b8d; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.metrics, .analysis-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.analysis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metrics article { padding: 20px; border: 1px solid var(--line); background: #fff; border-radius: 8px; display: grid; grid-template-columns: 26px 1fr; gap: 4px 9px; }
.metrics .el-icon { color: #3574a8; grid-row: span 2; font-size: 21px; } .metrics span { color: #657689; font-size: 13px; } .metrics b { color: var(--ink); font-size: 24px; }
@media (max-width: 760px) { .page-intro { align-items: start; flex-direction: column; } .metrics, .analysis-grid { grid-template-columns: 1fr; } }
</style>
+117 -53
View File
@@ -2,14 +2,17 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
Check, Check,
Download,
EditPen, EditPen,
Plus, Plus,
Promotion, Promotion,
Refresh, Refresh,
Setting, Setting,
Upload,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { import {
academicTermLabel, academicTermLabel,
@@ -33,8 +36,8 @@ const collegeId = ref('')
const projectKeyword = ref('') const projectKeyword = ref('')
const projectPage = ref(1) const projectPage = ref(1)
const projectPageSize = ref(20) const projectPageSize = ref(20)
const projectTotal = ref(0) const courseTotal = ref(0)
const projects = ref<any[]>([]) const courses = ref<any[]>([])
const studentResults = ref<any[]>([]) const studentResults = ref<any[]>([])
const detailDrawer = ref(false) const detailDrawer = ref(false)
const detail = ref<any | null>(null) const detail = ref<any | null>(null)
@@ -44,6 +47,7 @@ const recordKeyword = ref('')
const schemeDialog = ref(false) const schemeDialog = ref(false)
const editingScheme = ref(false) const editingScheme = ref(false)
const schemeProject = ref<any | null>(null) const schemeProject = ref<any | null>(null)
const importFileInput = ref<HTMLInputElement>()
const schemeForm = reactive({ const schemeForm = reactive({
contributionWeight: 1, contributionWeight: 1,
@@ -86,6 +90,18 @@ const modeLabels: Record<string, string> = {
Centralized: '集中安排', Centralized: '集中安排',
SelfScheduled: '自主预约', SelfScheduled: '自主预约',
} }
const weekdayLabels = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
function projectScheduleLabel(project: any) {
const entry = project.scheduleEntry
if (!entry) return modeLabels[project.arrangementMode] ?? '待安排'
const week = project.scheduleWeek ?? '—'
const endPeriod = entry.startPeriod + entry.periodCount - 1
const periods = entry.periodCount === 1
? `${entry.startPeriod}`
: `${entry.startPeriod}${endPeriod}`
return `${week} 周 · ${weekdayLabels[entry.dayOfWeek] ?? `${entry.dayOfWeek}`} · ${periods}`
}
const schemeWeightTotal = computed(() => const schemeWeightTotal = computed(() =>
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0), schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
@@ -196,8 +212,8 @@ async function load() {
pageSize: projectPageSize.value, pageSize: projectPageSize.value,
}, },
})).data })).data
projects.value = data.items courses.value = data.items
projectTotal.value = data.total courseTotal.value = data.total
projectPage.value = data.page projectPage.value = data.page
projectPageSize.value = data.pageSize projectPageSize.value = data.pageSize
} }
@@ -290,6 +306,38 @@ async function saveRecords() {
} }
} }
function downloadTemplate() {
if (!detail.value) return
downloadApiFile(
`/experiment-grades/sheets/${detail.value.id}/template`,
'实验成绩导入模板.xlsx',
)
}
function chooseImportFile() {
importFileInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !detail.value) return
saving.value = true
try {
const result = await importExcel(
`/experiment-grades/sheets/${detail.value.id}/import`,
file,
)
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条实验成绩记录`)
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
input.value = ''
saving.value = false
}
}
async function syncParticipants() { async function syncParticipants() {
if (!detail.value) return if (!detail.value) return
try { try {
@@ -528,45 +576,52 @@ onMounted(async () => {
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button> <el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
</section> </section>
<section v-loading="loading" class="grade-project-list"> <section v-loading="loading" class="grade-course-list">
<article v-for="project in projects" :key="project.id" class="grade-project-card"> <article v-for="course in courses" :key="course.teachingTaskId" class="grade-course-card">
<div class="project-mark"> <header class="grade-course-head">
<span>{{ project.code }}</span> <div>
<b>{{ modeLabels[project.arrangementMode] }}</b> <span>{{ course.termName }} · {{ course.taskNumber }}</span>
</div> <h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
<div class="project-summary"> <small>{{ course.teacherNames.join('、') || '教师待定' }} · {{ course.collegeName }}</small>
<h3>{{ project.name }}</h3>
<p>{{ project.courseCode }} · {{ project.courseName }}</p>
<small>{{ project.taskNumber }} · {{ project.termName }} · {{ project.teacherNames.join('、') || '教师待定' }}</small>
</div>
<template v-if="project.sheet">
<div class="sheet-progress">
<el-tag :type="statusTypes[project.sheet.status] as any" effect="plain">
{{ statusLabels[project.sheet.status] }}
</el-tag>
<span><b>{{ project.sheet.scoredCount }}</b> / {{ project.sheet.studentCount }} 已计分</span>
<span>评分项 {{ project.sheet.itemCount }} · 合格线 {{ project.sheet.passScore }}</span>
</div> </div>
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)"> <div class="course-rollup">
打开成绩单 <b>{{ course.projectCount }}</b><span>个实验项目</span>
</el-button> <small v-if="course.sheetCount">{{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分</small>
</template> <small v-else>尚未建立成绩单</small>
<template v-else>
<div class="sheet-empty">
<b>尚未建立实验成绩单</b>
<span>建立时会按集中名单或有效预约生成评分对象</span>
</div> </div>
<el-button type="primary" :icon="Plus" @click="openCreate(project)"> </header>
建立成绩单 <el-table :data="course.projects" size="small" class="course-project-table">
</el-button> <el-table-column label="实验项目" min-width="260">
</template> <template #default="{ row }">
<div class="course-project-name">
<b>{{ row.code }} · {{ row.name }}</b>
<small>{{ projectScheduleLabel(row) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="成绩进度" min-width="190">
<template #default="{ row }">
<div v-if="row.sheet" class="course-project-progress">
<el-tag :type="statusTypes[row.sheet.status] as any" size="small" effect="plain">{{ statusLabels[row.sheet.status] }}</el-tag>
<span>{{ row.sheet.scoredCount }}/{{ row.sheet.studentCount }} 已计分 · {{ row.sheet.itemCount }} </span>
</div>
<span v-else class="table-muted">尚未建立成绩单</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="right">
<template #default="{ row }">
<el-button v-if="row.sheet" type="primary" text :icon="EditPen" @click="openDetail(row)">打开成绩单</el-button>
<el-button v-else type="primary" text :icon="Plus" @click="openCreate(row)">建立成绩单</el-button>
</template>
</el-table-column>
</el-table>
</article> </article>
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" /> <el-empty v-if="!loading && !courses.length" description="当前筛选条件下没有可评分实验课程" />
</section> </section>
<el-pagination <el-pagination
v-model:current-page="projectPage" v-model:current-page="projectPage"
v-model:page-size="projectPageSize" v-model:page-size="projectPageSize"
:total="projectTotal" :total="courseTotal"
:page-sizes="[10, 20, 50]" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
@current-change="load" @current-change="load"
@@ -637,6 +692,7 @@ onMounted(async () => {
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span> <span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
<h3>{{ detail.projectName }}</h3> <h3>{{ detail.projectName }}</h3>
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p> <p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
<p class="project-schedule">{{ projectScheduleLabel(detail) }}</p>
</div> </div>
<div class="workbench-status"> <div class="workbench-status">
<el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag> <el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag>
@@ -777,12 +833,16 @@ onMounted(async () => {
@size-change="recordPage = 1; loadDetail()" @size-change="recordPage = 1; loadDetail()"
/> />
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
<footer class="workbench-actions"> <footer class="workbench-actions">
<div> <div>
<b v-if="detail.reviewComment">审核意见{{ detail.reviewComment }}</b> <b v-if="detail.reviewComment">审核意见{{ detail.reviewComment }}</b>
<span>开课学院{{ detail.courseCollegeName }}</span> <span>开课学院{{ detail.courseCollegeName }}</span>
</div> </div>
<div> <div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button>
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button> <el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
<el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button> <el-button v-if="detail.canReview" type="danger" plain @click="returnSheet">退回修改</el-button>
@@ -810,18 +870,24 @@ onMounted(async () => {
.grade-toolbar .el-select { width: 220px; } .grade-toolbar .el-select { width: 220px; }
.grade-toolbar .el-input { width: min(320px, 100%); } .grade-toolbar .el-input { width: min(320px, 100%); }
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; } .grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
.grade-project-list { display: grid; gap: 12px; min-height: 180px; } .grade-course-list { display: grid; gap: 10px; min-height: 180px; }
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); } .grade-course-card { overflow: hidden; border: 1px solid #d9e4e8; border-left: 4px solid var(--grade-blue); background: #fff; box-shadow: 0 4px 13px rgb(30 68 86 / 4%); }
.project-mark { display: grid; gap: 7px; } .grade-course-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f3f7f8; }
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; } .grade-course-head > div:first-child { min-width: 0; }
.project-mark b { color: var(--grade-blue); font-size: 12px; } .grade-course-head span { color: #607b88; font: 700 10px/1.3 Consolas, monospace; }
.project-summary { min-width: 0; } .grade-course-head h3 { margin: 3px 0; color: var(--grade-ink); font-size: 15px; }
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; } .grade-course-head small { display: block; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; } .course-rollup { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 5px; flex: none; text-align: right; }
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } .course-rollup b { color: var(--grade-teal); font: 750 22px/1 Consolas, monospace; }
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; } .course-rollup > span { color: #55707b; font: 600 11px/1.2 inherit; }
.sheet-progress b { color: var(--grade-teal); } .course-rollup small { grid-column: 1 / -1; margin-top: 3px; color: #70858d; font-size: 10px; }
.sheet-empty b { color: #5d7480; } .course-project-table { width: 100%; --el-table-border-color: #e0e8ea; }
.course-project-table :deep(td.el-table__cell), .course-project-table :deep(th.el-table__cell) { padding: 6px 0; }
.course-project-table :deep(th.el-table__cell) { color: #71858d; font-size: 10px; }
.course-project-name { display: grid; gap: 2px; }
.course-project-name b { color: #294d5c; font-size: 12px; }
.course-project-name small, .course-project-progress { color: #6d838b; font-size: 10px; }
.course-project-progress { display: flex; align-items: center; gap: 7px; }
.student-course-results { display: grid; gap: 12px; min-height: 180px; } .student-course-results { display: grid; gap: 12px; min-height: 180px; }
.student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; } .student-course-result { overflow: hidden; border: 1px solid #cfdee3; background: #fff; }
.student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; } .student-course-head { min-height: 76px; padding: 11px 14px 11px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-left: 5px solid var(--grade-blue); background: #edf4f6; }
@@ -902,9 +968,7 @@ onMounted(async () => {
.workbench-actions b { color: #a24f36; font-size: 12px; } .workbench-actions b { color: #a24f36; font-size: 12px; }
.workbench-actions span { color: var(--muted); font-size: 12px; } .workbench-actions span { color: var(--muted); font-size: 12px; }
@media (max-width: 980px) { @media (max-width: 980px) {
.grade-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; } .grade-course-head { align-items: flex-start; }
.sheet-progress, .sheet-empty { grid-column: 2; }
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.assessment-flow { grid-template-columns: 1fr; } .assessment-flow { grid-template-columns: 1fr; }
@@ -912,8 +976,8 @@ onMounted(async () => {
.grade-toolbar { align-items: stretch; flex-direction: column; } .grade-toolbar { align-items: stretch; flex-direction: column; }
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; } .grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
.grade-toolbar > span { margin-left: 0; } .grade-toolbar > span { margin-left: 0; }
.grade-project-card { grid-template-columns: 1fr; } .grade-course-head { align-items: stretch; flex-direction: column; }
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; } .course-rollup { align-self: flex-start; text-align: left; }
.student-result-grid { grid-template-columns: 1fr; } .student-result-grid { grid-template-columns: 1fr; }
.student-result-card > header { align-items: stretch; flex-direction: column; } .student-result-card > header { align-items: stretch; flex-direction: column; }
.score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; } .score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; }
+35 -8
View File
@@ -37,6 +37,7 @@ const taskKeyword = ref('')
const projectDialog = ref(false) const projectDialog = ref(false)
const editingProjectId = ref('') const editingProjectId = ref('')
const correctingPublishedProject = ref(false)
const projectForm = reactive({ const projectForm = reactive({
teachingTaskIds: [] as string[], teachingTaskIds: [] as string[],
scheduleEntryId: '', scheduleEntryId: '',
@@ -158,6 +159,7 @@ const statusLabels: Record<string, string> = {
function resetProjectForm() { function resetProjectForm() {
editingProjectId.value = '' editingProjectId.value = ''
correctingPublishedProject.value = false
Object.assign(projectForm, { Object.assign(projectForm, {
teachingTaskIds: [], teachingTaskIds: [],
scheduleEntryId: '', scheduleEntryId: '',
@@ -197,6 +199,7 @@ function openCreateProject() {
function openEditProject(project: any) { function openEditProject(project: any) {
editingProjectId.value = project.id editingProjectId.value = project.id
correctingPublishedProject.value = false
Object.assign(projectForm, { Object.assign(projectForm, {
teachingTaskIds: [project.teachingTaskId], teachingTaskIds: [project.teachingTaskId],
scheduleEntryId: project.scheduleEntryId ?? '', scheduleEntryId: project.scheduleEntryId ?? '',
@@ -212,18 +215,23 @@ function openEditProject(project: any) {
projectDialog.value = true projectDialog.value = true
} }
function openCorrectPublishedProject(project: any) {
openEditProject(project)
correctingPublishedProject.value = true
}
function projectNameLines(value: string) { function projectNameLines(value: string) {
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean) return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
} }
async function saveProject() { async function saveProject() {
const names = projectNameLines(projectForm.projectNames) const names = projectNameLines(projectForm.projectNames)
const hasValidName = editingProjectId.value const hasValidName = correctingPublishedProject.value || editingProjectId.value
? !!projectForm.name.trim() ? !!projectForm.name.trim()
: projectForm.nameMode === 'InputNames' : projectForm.nameMode === 'InputNames'
? names.length > 0 ? names.length > 0
: !!projectForm.name.trim() : !!projectForm.name.trim()
if (!projectForm.teachingTaskIds.length || if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) || (editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
!projectForm.code.trim() !projectForm.code.trim()
|| !hasValidName || projectForm.dates.length !== 2) { || !hasValidName || projectForm.dates.length !== 2) {
@@ -244,7 +252,14 @@ async function saveProject() {
endDate: projectForm.dates[1], endDate: projectForm.dates[1],
} }
try { try {
if (editingProjectId.value) { if (correctingPublishedProject.value) {
await http.put(`/experiments/${editingProjectId.value}/published-details`, {
name: projectForm.name,
description: projectForm.description || null,
requirements: projectForm.requirements || null,
})
ElMessage.success('已修正已发布项目的教学内容,并通知学生查看')
} else if (editingProjectId.value) {
await http.put(`/experiments/${editingProjectId.value}`, payload) await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新') ElMessage.success('实验项目已更新')
} else { } else {
@@ -842,7 +857,10 @@ onMounted(async () => {
@click="publishProject(row)" @click="publishProject(row)"
>发布</el-button> >发布</el-button>
</template> </template>
<el-button v-else-if="row.status === 'Published'" size="small" text @click="closeProject(row)">关闭项目</el-button> <template v-else-if="row.status === 'Published'">
<el-button size="small" text @click="openCorrectPublishedProject(row)">修正内容</el-button>
<el-button size="small" text @click="closeProject(row)">关闭项目</el-button>
</template>
<el-button <el-button
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'" v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
size="small" size="small"
@@ -952,11 +970,17 @@ onMounted(async () => {
<el-dialog <el-dialog
v-model="projectDialog" v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'" :title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
width="720px" width="720px"
top="5vh" top="5vh"
> >
<el-form label-position="top" class="experiment-form"> <el-form label-position="top" class="experiment-form">
<el-alert
v-if="correctingPublishedProject"
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
type="info"
:closable="false"
/>
<div class="form-section"> <div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header> <header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required> <el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
@@ -1027,7 +1051,7 @@ onMounted(async () => {
</el-form-item> </el-form-item>
<div class="form-grid two"> <div class="form-grid two">
<el-form-item label="项目编码" required> <el-form-item label="项目编码" required>
<el-input v-model="projectForm.code" maxlength="40" placeholder="如 LAB-01" /> <el-input v-model="projectForm.code" :disabled="correctingPublishedProject" maxlength="40" placeholder="如 LAB-01" />
</el-form-item> </el-form-item>
<el-form-item v-if="editingProjectId" label="项目名称" required> <el-form-item v-if="editingProjectId" label="项目名称" required>
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" /> <el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
@@ -1058,7 +1082,7 @@ onMounted(async () => {
<div class="form-section"> <div class="form-section">
<header><span>ROUTE</span><b>选择运行轨道</b></header> <header><span>ROUTE</span><b>选择运行轨道</b></header>
<el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId"> <el-radio-group v-model="projectForm.arrangementMode" class="mode-choice" :disabled="!!editingProjectId || correctingPublishedProject">
<el-radio-button value="Centralized"> <el-radio-button value="Centralized">
<b>集中安排</b><small>复用已发布课表的实验课</small> <b>集中安排</b><small>复用已发布课表的实验课</small>
</el-radio-button> </el-radio-button>
@@ -1069,6 +1093,7 @@ onMounted(async () => {
<el-form-item label="项目开放日期" required> <el-form-item label="项目开放日期" required>
<el-date-picker <el-date-picker
v-model="projectForm.dates" v-model="projectForm.dates"
:disabled="correctingPublishedProject"
type="daterange" type="daterange"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
range-separator="至" range-separator="至"
@@ -1105,7 +1130,9 @@ onMounted(async () => {
<template #footer> <template #footer>
<el-button @click="projectDialog = false">取消</el-button> <el-button @click="projectDialog = false">取消</el-button>
<el-button type="primary" @click="saveProject"> <el-button type="primary" @click="saveProject">
{{ editingProjectId {{ correctingPublishedProject
? '保存修正'
: editingProjectId
? '保存项目' ? '保存项目'
: projectForm.teachingTaskIds.length : projectForm.teachingTaskIds.length
? `创建 ${projectForm.teachingTaskIds.length} 个项目` ? `创建 ${projectForm.teachingTaskIds.length} 个项目`
+12 -1
View File
@@ -58,6 +58,7 @@ const unreadCount = ref(0)
const total = ref(0) const total = ref(0)
const sentTotal = ref(0) const sentTotal = ref(0)
const loading = ref(false) const loading = ref(false)
const markingAllRead = ref(false)
const sending = ref(false) const sending = ref(false)
const composerLoading = ref(false) const composerLoading = ref(false)
const recipientLoading = ref(false) const recipientLoading = ref(false)
@@ -380,6 +381,8 @@ async function openNotification(notification: any) {
} }
async function markAllRead() { async function markAllRead() {
if (markingAllRead.value) return
markingAllRead.value = true
try { try {
await http.post('/notifications/read-all') await http.post('/notifications/read-all')
notifications.value.forEach(notification => { notifications.value.forEach(notification => {
@@ -389,6 +392,8 @@ async function markAllRead() {
ElMessage.success('全部消息已标为已读') ElMessage.success('全部消息已标为已读')
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally {
markingAllRead.value = false
} }
} }
@@ -541,7 +546,13 @@ onMounted(() => load())
@clear="load(true)" @clear="load(true)"
/> />
<el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox> <el-checkbox v-model="unreadOnly" @change="load(true)">仅看未读</el-checkbox>
<el-button v-if="unreadCount" :icon="Check" text @click="markAllRead"> <el-button
v-if="unreadCount"
:icon="Check"
:loading="markingAllRead"
text
@click="markAllRead"
>
全部已读 全部已读
</el-button> </el-button>
</section> </section>
+17
View File
@@ -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() { async function saveEntry() {
if (!entryForm.teachingTaskId || if (!entryForm.teachingTaskId ||
((entryForm.kind === 'Experiment' || ((entryForm.kind === 'Experiment' ||
@@ -937,6 +947,13 @@ onBeforeUnmount(() => {
> >
复制调整 复制调整
</el-button> </el-button>
<el-button
v-if="isDraft"
:disabled="scheduleJobLoading"
@click="preflightSchedule"
>
排课前检查
</el-button>
<el-button <el-button
v-if="isDraft" v-if="isDraft"
type="warning" type="warning"
+139 -2
View File
@@ -70,6 +70,27 @@ interface GradeItem {
publishedAt?: string 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 router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(true) const loading = ref(true)
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
const unreadCount = ref(0) const unreadCount = ref(0)
const exams = ref<ExamItem[]>([]) const exams = ref<ExamItem[]>([])
const grades = ref<GradeItem[]>([]) 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 now = ref(new Date())
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
@@ -159,6 +183,11 @@ const recentGrades = computed(() =>
.slice(0, 4), .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) { function parseDateOnly(value?: string) {
if (!value) return null if (!value) return null
const [year, month, day] = value.slice(0, 10).split('-').map(Number) 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('/notifications', { params: { page: 1, pageSize: 5 } }),
http.get('/exams/my-schedule'), http.get('/exams/my-schedule'),
http.get('/grades/student/transcript'), 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') { if (results[0].status === 'fulfilled') {
@@ -304,6 +336,15 @@ async function loadOverview() {
} else { } else {
failedSections.value.push('考试成绩') 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 loading.value = false
} }
@@ -315,8 +356,8 @@ onMounted(loadOverview)
<section class="student-overview-hero"> <section class="student-overview-hero">
<div class="student-hero-copy"> <div class="student-hero-copy">
<span class="section-kicker">MY ACADEMIC DAY</span> <span class="section-kicker">MY ACADEMIC DAY</span>
<h2>{{ greeting }}{{ auth.user?.displayName ?? '同学' }}</h2> <h2>{{ dashboardGreeting?.title ?? `${greeting}${auth.user?.displayName ?? '同学'}` }}</h2>
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p> <p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
</div> </div>
<div class="today-status"> <div class="today-status">
<span>今日课程</span> <span>今日课程</span>
@@ -325,6 +366,39 @@ onMounted(loadOverview)
</div> </div>
</section> </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 <el-alert
v-if="failedSections.length" v-if="failedSections.length"
type="warning" type="warning"
@@ -539,6 +613,63 @@ onMounted(loadOverview)
font-size: 13px; 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 { .today-status {
min-width: 180px; min-width: 180px;
margin-left: auto; margin-left: auto;
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
.student-overview-grid { .student-overview-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 600px) { @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 { .student-overview {
gap: 12px; gap: 12px;
} }
+55
View File
@@ -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>