Compare commits
@@ -8,6 +8,9 @@ MYSQL_USER=jiaowu
|
||||
MYSQL_PASSWORD=
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
|
||||
CLICKHOUSE_USER=jiaowu_analytics
|
||||
CLICKHOUSE_PASSWORD=
|
||||
|
||||
RABBITMQ_USER=jiaowu
|
||||
RABBITMQ_PASSWORD=
|
||||
BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY=1
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
ASPNETCORE_ENVIRONMENT=Production
|
||||
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__ApplyMigrationsOnStartup=false
|
||||
Database__CommandTimeoutSeconds=30
|
||||
@@ -55,6 +62,18 @@ PerformanceReporting__Enabled=false
|
||||
PerformanceReporting__CacheSeconds=30
|
||||
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__BackupWarningHours=24
|
||||
|
||||
Submodule
+1
Submodule Academic-Affairs-System.wiki added at fe88e71716
@@ -415,6 +415,22 @@ Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置
|
||||
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
|
||||
`*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
|
||||
|
||||
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
|
||||
|
||||
@@ -16,6 +16,11 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||
Cache__Enabled: "true"
|
||||
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__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||
@@ -47,6 +52,25 @@ x-json-logging: &json-logging
|
||||
max-file: "3"
|
||||
|
||||
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:
|
||||
image: rabbitmq:4.2-management-alpine
|
||||
restart: unless-stopped
|
||||
@@ -135,6 +159,8 @@ services:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
@@ -165,5 +191,6 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
clickhouse-data:
|
||||
rabbitmq-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,
|
||||
counts,
|
||||
pending,
|
||||
await BuildGreetingAsync(scope, currentTermId, counts, pending, cancellationToken),
|
||||
DateTime.UtcNow));
|
||||
}
|
||||
|
||||
[HttpGet("greeting")]
|
||||
public async Task<ActionResult<DashboardGreeting>> GetGreeting(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTermId = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsCurrent)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return Ok(await BuildGreetingAsync(
|
||||
currentUserDataScope.Current,
|
||||
currentTermId,
|
||||
null,
|
||||
null,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<DashboardGreeting> BuildGreetingAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? currentTermId,
|
||||
DashboardCounts? counts,
|
||||
DashboardPending? pending,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(scope.DisplayName) ? "" : $"{scope.DisplayName},";
|
||||
var greeting = GetTimeGreeting();
|
||||
var isManager = scope.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
scope.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
scope.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
scope.IsInRole(SystemRoles.Leader) ||
|
||||
scope.IsInRole(SystemRoles.Counselor);
|
||||
|
||||
if (!isManager && scope.IsInRole(SystemRoles.Student))
|
||||
{
|
||||
var studentId = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == scope.UserId)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return new DashboardGreeting("Student", $"{name}{greeting}", "绑定学籍后,将为你生成课程与成绩学习概览。", "学习节奏", "关联学生档案后,可从课程安排、成绩和考试中生成学习状态摘要。", []);
|
||||
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.Id == studentId.Value)
|
||||
.Select(x => new { x.AdministrativeClassId })
|
||||
.FirstAsync(cancellationToken);
|
||||
var currentTasks = db.TeachingTasks.AsNoTracking().Where(task =>
|
||||
currentTermId.HasValue &&
|
||||
task.AcademicTermId == currentTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == student.AdministrativeClassId) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == studentId.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)));
|
||||
var taskWorkload = await currentTasks.Select(task => new
|
||||
{
|
||||
task.Id,
|
||||
task.CourseId,
|
||||
Credits = task.Course!.Credits,
|
||||
IsClassAssigned = task.Classes.Any(item =>
|
||||
item.AdministrativeClassId == student.AdministrativeClassId)
|
||||
}).ToListAsync(cancellationToken);
|
||||
var currentCourses = taskWorkload
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
var courseCount = currentCourses.Count;
|
||||
var courseCredits = currentCourses.Sum(x => x.Credits);
|
||||
var classAssignedCount = taskWorkload.Count(x => x.IsClassAssigned);
|
||||
var selfSelectedCount = taskWorkload.Count(x => !x.IsClassAssigned);
|
||||
var publishedGrades = db.GradeRecords.AsNoTracking().Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
currentTermId.HasValue &&
|
||||
x.GradeSheet.TeachingTask!.AcademicTermId == currentTermId.Value);
|
||||
var gradeCount = await publishedGrades.CountAsync(cancellationToken);
|
||||
var average = await publishedGrades
|
||||
.Where(x => x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue)
|
||||
.AverageAsync(x => (decimal?)x.TotalScore, cancellationToken);
|
||||
var failed = await publishedGrades.CountAsync(x =>
|
||||
x.ExamStatus == GradeExamStatus.Normal && x.TotalScore.HasValue && x.TotalScore < 60,
|
||||
cancellationToken);
|
||||
|
||||
var subtitle = failed > 0
|
||||
? $"已发布成绩中有 {failed} 门课程需要重点关注,建议优先查看课程反馈。"
|
||||
: courseCount > 0
|
||||
? $"本学期已有 {courseCount} 门课程、{courseCredits:0.#} 学分进入你的学习安排。"
|
||||
: "本学期暂未发现为你安排或确认选课的课程,可先查看培养方案和选课安排。";
|
||||
var narrative = courseCount == 0
|
||||
? "你的当前学习安排尚未形成:系统还没有找到行政班已安排课程或已确认选课。"
|
||||
: failed > 0
|
||||
? $"本学期已形成 {courseCount} 门课程安排,其中 {classAssignedCount} 个教学班来自行政班安排;已发布成绩中有 {failed} 门需要重点关注。"
|
||||
: gradeCount > 0
|
||||
? $"本学期有 {courseCount} 门课程进入学习安排,已发布 {gradeCount} 门成绩,当前没有不及格记录。"
|
||||
: $"本学期有 {courseCount} 门课程进入学习安排,包含 {classAssignedCount} 个行政班教学班和 {selfSelectedCount} 个自主选课教学班,成绩发布后会在这里更新。";
|
||||
return new DashboardGreeting("Student", $"{name}{greeting}", subtitle, "学习节奏", narrative,
|
||||
[
|
||||
new DashboardGreetingInsight("本学期课程", $"{courseCount} 门", $"共 {courseCredits:0.#} 学分", "calm"),
|
||||
new DashboardGreetingInsight("已发布成绩", $"{gradeCount} 门", average.HasValue ? $"平均分 {average.Value:0.0}" : "等待成绩发布", "calm"),
|
||||
new DashboardGreetingInsight("重点关注", $"{failed} 门", failed > 0 ? "建议尽早安排复习与答疑" : "当前无不及格记录", failed > 0 ? "attention" : "positive")
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isManager && scope.IsInRole(SystemRoles.Teacher))
|
||||
{
|
||||
var teacherId = await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.UserId == scope.UserId && x.Status == TeacherStatus.Active)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (!teacherId.HasValue)
|
||||
return new DashboardGreeting("Teacher", $"{name}{greeting}", "绑定教师档案后,将为你生成本学期教学负荷概览。", "教学节奏", "关联教师档案后,可从教学班、授课学时和成绩进度生成今日工作摘要。", []);
|
||||
|
||||
var tasks = db.TeachingTasks.AsNoTracking().Where(x =>
|
||||
currentTermId.HasValue && x.AcademicTermId == currentTermId.Value &&
|
||||
x.Teachers.Any(t => t.TeacherId == teacherId.Value));
|
||||
var teachingClasses = await tasks.CountAsync(cancellationToken);
|
||||
var estimatedHours = await tasks.SumAsync(
|
||||
x => (int?)(x.WeeklyHours * (x.EndWeek - x.StartWeek + 1)), cancellationToken) ?? 0;
|
||||
var gradeSheets = db.GradeSheets.AsNoTracking().Where(x =>
|
||||
x.TeachingTask!.Teachers.Any(t => t.TeacherId == teacherId.Value) &&
|
||||
currentTermId.HasValue && x.TeachingTask.AcademicTermId == currentTermId.Value);
|
||||
var pendingGrades = await gradeSheets.CountAsync(x =>
|
||||
x.Status == GradeSheetStatus.Draft ||
|
||||
x.Status == GradeSheetStatus.Returned, cancellationToken);
|
||||
var submittedGrades = await gradeSheets.CountAsync(x => x.Status == GradeSheetStatus.Submitted, cancellationToken);
|
||||
var subtitle = pendingGrades > 0
|
||||
? $"有 {pendingGrades} 张成绩登记册尚待提交,完成后可进入审核流程。"
|
||||
: teachingClasses > 0
|
||||
? $"本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时。"
|
||||
: "本学期暂未分配教学班,请留意教学任务安排。";
|
||||
var narrative = pendingGrades > 0
|
||||
? $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时;有 {pendingGrades} 张成绩登记册等待提交。"
|
||||
: $"你本学期承担 {teachingClasses} 个教学班,预计授课 {estimatedHours} 学时,目前没有待提交的成绩登记册。";
|
||||
return new DashboardGreeting("Teacher", $"{name}{greeting}", subtitle, "教学节奏", narrative,
|
||||
[
|
||||
new DashboardGreetingInsight("教学班", $"{teachingClasses} 个", $"预计 {estimatedHours} 学时", "calm"),
|
||||
new DashboardGreetingInsight("待提交成绩", $"{pendingGrades} 张", pendingGrades > 0 ? "请在截止日前完成登记" : "当前无需提交", pendingGrades > 0 ? "attention" : "positive"),
|
||||
new DashboardGreetingInsight("审核中成绩", $"{submittedGrades} 张", submittedGrades > 0 ? "等待审核结果" : "暂无审核中登记册", "calm")
|
||||
]);
|
||||
}
|
||||
|
||||
var actionable = pending is null ? 0 : pending.TeacherApplications + pending.GradeSheets +
|
||||
pending.CourseAdjustments + pending.StudentStatusChanges + pending.GradeModifications +
|
||||
pending.ClassroomReservations + pending.GeneralApprovals;
|
||||
var taskCount = counts?.TeachingTasks ?? 0;
|
||||
var scheduledCount = counts?.ScheduledTeachingTasks ?? 0;
|
||||
var subtitleForManager = actionable > 0
|
||||
? $"当前有 {actionable} 项待办需要跟进,优先处理时效性审核事项。"
|
||||
: taskCount > 0
|
||||
? $"本学期 {taskCount} 个教学班正在运行,当前没有积压待办。"
|
||||
: "当前学期运行数据已就绪,可从教学任务开始推进。";
|
||||
var managerNarrative = actionable > 0
|
||||
? $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表;{actionable} 项待办正等待处理。"
|
||||
: $"当前教学运行覆盖 {taskCount} 个教学班,其中 {scheduledCount} 个已进入课表,暂未发现需要你处理的积压事项。";
|
||||
return new DashboardGreeting("Manager", $"{name}{greeting}", subtitleForManager, "运行态势", managerNarrative,
|
||||
[
|
||||
new DashboardGreetingInsight("当前待办", $"{actionable} 项", actionable > 0 ? "优先处理可操作事项" : "暂无积压", actionable > 0 ? "attention" : "positive"),
|
||||
new DashboardGreetingInsight("本学期教学班", $"{taskCount} 个", "教学运行规模", "calm"),
|
||||
new DashboardGreetingInsight("已进入课表", $"{scheduledCount} 个", taskCount > 0 ? $"覆盖 {Math.Round(scheduledCount * 100d / taskCount)}% 教学班" : "等待教学任务发布", "calm")
|
||||
]);
|
||||
}
|
||||
|
||||
private static string GetTimeGreeting()
|
||||
{
|
||||
var hour = DateTime.UtcNow.AddHours(8).Hour;
|
||||
return hour < 11 ? "早上好" : hour < 14 ? "中午好" : hour < 18 ? "下午好" : "晚上好";
|
||||
}
|
||||
|
||||
private async Task<DashboardPending> LoadPendingAsync(
|
||||
CurrentUserScope scope,
|
||||
Guid? restrictedCollegeId,
|
||||
@@ -276,8 +446,23 @@ public sealed record DashboardResponse(
|
||||
DashboardTerm? CurrentTerm,
|
||||
DashboardCounts Counts,
|
||||
DashboardPending Pending,
|
||||
DashboardGreeting Greeting,
|
||||
DateTime GeneratedAt);
|
||||
|
||||
public sealed record DashboardGreeting(
|
||||
string Role,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string Label,
|
||||
string Narrative,
|
||||
IReadOnlyList<DashboardGreetingInsight> Insights);
|
||||
|
||||
public sealed record DashboardGreetingInsight(
|
||||
string Label,
|
||||
string Value,
|
||||
string Hint,
|
||||
string Tone);
|
||||
|
||||
public sealed record DashboardAudience(
|
||||
string Level,
|
||||
string Title,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
@@ -74,19 +76,36 @@ public sealed class ExperimentGradesController(
|
||||
item.Teacher!.TeacherNumber.Contains(keyword) ||
|
||||
item.Teacher.Name.Contains(keyword)));
|
||||
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||
.ThenBy(x => x.Code)
|
||||
var total = await source.Select(x => x.TeachingTaskId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var taskIds = await source
|
||||
.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)
|
||||
.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
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ArrangementMode,
|
||||
x.ScheduleWeek,
|
||||
ProjectStatus = x.Status,
|
||||
x.TeachingTaskId,
|
||||
x.TeachingTask!.TaskNumber,
|
||||
@@ -99,6 +118,14 @@ public sealed class ExperimentGradesController(
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ScheduleEntry = x.ScheduleEntry == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
x.ScheduleEntry.DayOfWeek,
|
||||
x.ScheduleEntry.StartPeriod,
|
||||
x.ScheduleEntry.PeriodCount
|
||||
},
|
||||
Sheet = x.GradeSheet == null
|
||||
? null
|
||||
: new
|
||||
@@ -122,6 +149,32 @@ public sealed class ExperimentGradesController(
|
||||
}
|
||||
})
|
||||
.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
|
||||
{
|
||||
Items = items,
|
||||
@@ -316,6 +369,15 @@ public sealed class ExperimentGradesController(
|
||||
ProjectCode = x.ExperimentProject!.Code,
|
||||
ProjectName = x.ExperimentProject.Name,
|
||||
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.TeachingTask!.TaskNumber,
|
||||
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||
@@ -574,6 +636,166 @@ public sealed class ExperimentGradesController(
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> SyncParticipants(
|
||||
@@ -834,6 +1056,22 @@ public sealed class ExperimentGradesController(
|
||||
.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(
|
||||
ExperimentProject project,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -974,6 +1212,93 @@ public sealed class ExperimentGradesController(
|
||||
private static bool ValidScore(decimal? score) =>
|
||||
!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) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
|
||||
@@ -666,6 +666,39 @@ public sealed class ExperimentsController(
|
||||
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")]
|
||||
[Authorize(Roles = Managers)]
|
||||
public async Task<ActionResult> UpdateProjectNames(
|
||||
@@ -1442,6 +1475,16 @@ public sealed class ExperimentsController(
|
||||
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(
|
||||
CancellationToken cancellationToken) =>
|
||||
db.Students.FirstOrDefaultAsync(x =>
|
||||
@@ -1492,6 +1535,22 @@ public sealed class ExperimentsController(
|
||||
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)
|
||||
{
|
||||
var ids = projectIds?.Where(x => x != Guid.Empty).Distinct().ToList();
|
||||
@@ -1569,6 +1628,11 @@ public sealed record ExperimentProjectRequest(
|
||||
DateOnly EndDate,
|
||||
Guid? ScheduleEntryId = null);
|
||||
|
||||
public sealed record PublishedExperimentProjectCorrectionRequest(
|
||||
[Required, MaxLength(120)] string Name,
|
||||
[MaxLength(1000)] string? Description,
|
||||
[MaxLength(1000)] string? Requirements);
|
||||
|
||||
public sealed record ExperimentProjectBatchRequest(
|
||||
[Required] IReadOnlyList<Guid> TeachingTaskIds,
|
||||
[Required, MaxLength(40)] string Code,
|
||||
|
||||
@@ -706,7 +706,7 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||
|
||||
var rows = sheet.Records.Select(record =>
|
||||
{
|
||||
@@ -723,8 +723,8 @@ public sealed class GradesController(
|
||||
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||
values.Add(score);
|
||||
}
|
||||
values.Add(null);
|
||||
values.Add(record.FinalScore);
|
||||
values.Add(null);
|
||||
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||
@@ -745,9 +745,9 @@ public sealed class GradesController(
|
||||
|
||||
var regularColumn = 4;
|
||||
var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray();
|
||||
var totalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var finalColumn = totalColumn + 1;
|
||||
var statusColumn = finalColumn + 1;
|
||||
var finalColumn = regularColumn + itemColumns.Length + 1;
|
||||
var totalColumn = finalColumn + 1;
|
||||
var statusColumn = totalColumn + 1;
|
||||
var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) };
|
||||
weightedColumns.AddRange(sheet.Items.Select((item, index) =>
|
||||
(itemColumns[index], item.Weight)));
|
||||
@@ -819,7 +819,7 @@ public sealed class GradesController(
|
||||
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||
headers.AddRange(itemNames);
|
||||
headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
|
||||
headers.AddRange(["期末成绩", "总分(自动计算)", "考试状态", "备注"]);
|
||||
|
||||
IReadOnlyList<ExcelRow> rows;
|
||||
try
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class NotificationsController(
|
||||
var total = await source.CountAsync(cancellationToken);
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.ThenByDescending(x => x.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(x => new
|
||||
|
||||
@@ -354,6 +354,25 @@ public sealed class SchedulesController(
|
||||
ToResponse(job));
|
||||
}
|
||||
|
||||
[HttpGet("plans/{planId:guid}/preflight")]
|
||||
public async Task<ActionResult> Preflight(Guid planId, CancellationToken cancellationToken)
|
||||
{
|
||||
var plan = await DraftPlanAsync(planId, cancellationToken);
|
||||
if (plan is null) return NotFound();
|
||||
var tasks = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(x => x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published &&
|
||||
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||
.Select(x => new { x.Id, x.Name, CourseName = x.Course!.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var scheduled = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x => x.SchedulePlanId == planId)
|
||||
.Select(x => x.TeachingTaskId).Distinct().ToListAsync(cancellationToken);
|
||||
var missing = tasks.Where(x => !scheduled.Contains(x.Id))
|
||||
.Select(x => $"《{x.CourseName}》{x.Name}").Take(20).ToList();
|
||||
return Ok(new { totalTasks = tasks.Count, scheduledTasks = scheduled.Count, unscheduledTasks = missing.Count, messages = missing });
|
||||
}
|
||||
|
||||
[HttpGet("auto-schedule-jobs/{jobId:guid}")]
|
||||
public async Task<ActionResult<AutomaticScheduleJobResponse>>
|
||||
GetAutomaticScheduleJob(
|
||||
|
||||
@@ -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>()
|
||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||
.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)
|
||||
@@ -1086,6 +1094,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
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.HasIndex(x => new { x.ExamPlanId, x.StartsAt });
|
||||
entity.HasIndex(x => x.TeachingTaskId);
|
||||
@@ -1112,6 +1124,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<ExamRoomAssignment>(entity =>
|
||||
{
|
||||
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 })
|
||||
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||
entity.HasIndex(x => new
|
||||
@@ -1200,6 +1214,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
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.HasIndex(x => new { x.MakeupExamPlanId, x.StartsAt });
|
||||
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.Content).HasColumnType("longtext");
|
||||
entity.Property(x => x.LinkUrl).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.UserId, x.IsRead });
|
||||
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
|
||||
// Each inbox query starts with its recipient. Keep the selected
|
||||
// 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.CreatedAt);
|
||||
entity.HasOne(x => x.MessageDispatch)
|
||||
@@ -1643,3 +1663,15 @@ public sealed class TimeOnlyTimeSpanConverter()
|
||||
: ValueConverter<TimeOnly, TimeSpan>(
|
||||
time => time.ToTimeSpan(),
|
||||
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";
|
||||
private const string ExperimentCourseGradesMigration =
|
||||
"20260809_54_experiment_course_grades";
|
||||
private const string NotificationInboxIndexesMigration =
|
||||
"20260810_55_notification_inbox_indexes";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -731,6 +733,10 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ExperimentCourseGradesMigration,
|
||||
experimentCourseGradesExist ? [] : ExperimentCourseGradesStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
NotificationInboxIndexesMigration,
|
||||
NotificationInboxIndexesStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -2999,6 +3005,15 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""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 =
|
||||
[
|
||||
"""
|
||||
|
||||
+6936
File diff suppressed because it is too large
Load Diff
+63
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -3417,9 +3417,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
@@ -6,6 +7,7 @@ using Jiaowu.Api.Infrastructure.BackgroundJobs;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Analytics;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
@@ -19,6 +21,7 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -101,12 +104,30 @@ var observabilityOptions = builder.Configuration
|
||||
var performanceReportingOptions = builder.Configuration
|
||||
.GetSection(PerformanceReportingOptions.SectionName)
|
||||
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
|
||||
var clickHouseAnalyticsOptions = builder.Configuration
|
||||
.GetSection(ClickHouseAnalyticsOptions.SectionName)
|
||||
.Get<ClickHouseAnalyticsOptions>() ?? new ClickHouseAnalyticsOptions();
|
||||
var rabbitMqOptions = builder.Configuration
|
||||
.GetSection(RabbitMqOptions.SectionName)
|
||||
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
|
||||
var ssoOptions = builder.Configuration
|
||||
.GetSection(SsoOptions.SectionName)
|
||||
.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 &&
|
||||
(string.IsNullOrWhiteSpace(ssoOptions.ClientId) ||
|
||||
@@ -252,6 +273,19 @@ if (backgroundJobOptions.UsesRabbitMq &&
|
||||
{
|
||||
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 &&
|
||||
!builder.Environment.IsDevelopment() &&
|
||||
(rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -282,6 +316,7 @@ builder.Services.AddSingleton(backgroundJobOptions);
|
||||
builder.Services.AddSingleton(operationsOptions);
|
||||
builder.Services.AddSingleton(observabilityOptions);
|
||||
builder.Services.AddSingleton(performanceReportingOptions);
|
||||
builder.Services.AddSingleton(clickHouseAnalyticsOptions);
|
||||
builder.Services.AddSingleton(rabbitMqOptions);
|
||||
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
|
||||
builder.Services.AddMemoryCache();
|
||||
@@ -290,6 +325,11 @@ builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
|
||||
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
|
||||
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.Configuration.GetSection(OfficialDocumentOptions.SectionName));
|
||||
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
|
||||
@@ -441,6 +481,7 @@ builder.Services.AddScoped<CourseGradeStatisticsRefreshJobProcessor>();
|
||||
builder.Services.AddScoped<CourseGradeStatisticsRefreshScheduler>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddHostedService<CourseGradeStatisticsRefreshWorker>();
|
||||
builder.Services.AddHostedService<ClickHouseAnalyticsProjectionWorker>();
|
||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||
builder.Services.AddScoped<OperationalHealthService>();
|
||||
@@ -530,6 +571,18 @@ if (ssoOptions.Enabled)
|
||||
});
|
||||
}
|
||||
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 =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
@@ -644,6 +697,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseForwardedHeaders();
|
||||
app.UseExceptionHandler();
|
||||
app.UseResponseCompression();
|
||||
app.Use(async (context, next) =>
|
||||
|
||||
@@ -39,6 +39,17 @@
|
||||
"SlowDatabaseMetric": "jiaowu_db_command_slow_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": {
|
||||
"BackupDirectory": "data/backups",
|
||||
"BackupWarningHours": 24,
|
||||
@@ -94,6 +105,12 @@
|
||||
"FrontendBaseUrl": "",
|
||||
"CallbackUrl": ""
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"TrustedProxies": [
|
||||
"127.0.0.1",
|
||||
"::1"
|
||||
]
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"capacitor://localhost",
|
||||
|
||||
@@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using ClosedXML.Excel;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests
|
||||
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]
|
||||
public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
|
||||
{
|
||||
@@ -146,10 +198,11 @@ public sealed class ExperimentGradesControllerTests
|
||||
1,
|
||||
10,
|
||||
CancellationToken.None));
|
||||
Assert.Equal(12, Property<int>(page.Value, "Total"));
|
||||
Assert.Equal(10, Property<System.Collections.IEnumerable>(
|
||||
page.Value,
|
||||
"Items").Cast<object>().Count());
|
||||
Assert.Equal(1, Property<int>(page.Value, "Total"));
|
||||
var course = Assert.Single(Property<System.Collections.IEnumerable>(
|
||||
page.Value, "Items").Cast<object>());
|
||||
Assert.Equal(12, Property<System.Collections.IEnumerable>(
|
||||
course, "Projects").Cast<object>().Count());
|
||||
|
||||
var filtered = Assert.IsType<OkObjectResult>(await controller.GetManagement(
|
||||
null,
|
||||
@@ -162,6 +215,89 @@ public sealed class ExperimentGradesControllerTests
|
||||
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) =>
|
||||
Assert.IsAssignableFrom<T>(
|
||||
value!.GetType().GetProperty(name)!.GetValue(value));
|
||||
|
||||
@@ -246,6 +246,35 @@ public sealed class ExperimentsControllerTests
|
||||
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]
|
||||
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
@@ -83,8 +84,12 @@ public sealed class GradesPaginationTests
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = tasks[0].Id,
|
||||
RegularWeight = 30,
|
||||
FinalWeight = 70,
|
||||
RegularWeight = 20,
|
||||
FinalWeight = 60,
|
||||
Items =
|
||||
[
|
||||
new GradeItem { Name = "实验", Weight = 20, SortOrder = 0 }
|
||||
],
|
||||
Records = students.Select(student => new GradeRecord
|
||||
{
|
||||
StudentId = student.Id
|
||||
@@ -129,6 +134,18 @@ public sealed class GradesPaginationTests
|
||||
var secondSheet = secondDetail.Value!.GetType().GetProperty("Sheet")!
|
||||
.GetValue(secondDetail.Value)!;
|
||||
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) =>
|
||||
|
||||
@@ -234,6 +234,53 @@ public sealed class NotificationsControllerTests
|
||||
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]
|
||||
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");
|
||||
|
||||
Assert.True(result.Content.Length > 5_000);
|
||||
Assert.True(result.Content.Length > 500, "生成的 PDF 不应为空。");
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4));
|
||||
Assert.Contains(System.Text.Encoding.ASCII.GetBytes("%%EOF"), result.Content);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(),
|
||||
result.Sha256);
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<JiaowuBackendVersion>2.3.2</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.3.2</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.3.2</JiaowuSwaggerVersion>
|
||||
<JiaowuBackendVersion>2.4.0-rc2</JiaowuBackendVersion>
|
||||
<JiaowuFrontendVersion>2.4.0-rc2</JiaowuFrontendVersion>
|
||||
<JiaowuSwaggerVersion>2.4.0-rc2</JiaowuSwaggerVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -194,6 +194,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader', 'Teacher']),
|
||||
{ path: '/grade-analytics', label: isTeacher.value ? '教学班成绩分析' : '成绩分析中心' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader']),
|
||||
{ path: '/event-analytics', label: '运行数据分析' },
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -263,6 +263,14 @@ const router = createRouter({
|
||||
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',
|
||||
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 selectedDistribution = computed(() => selectedRow.value?.value.distribution ?? [])
|
||||
const recommendation = computed(() => {
|
||||
const value = selectedRow.value?.value
|
||||
if (!value) return ''
|
||||
if (Number(value.passRate) < 70) return `合格率为 ${score(value.passRate)}%,建议优先复核低分段学生的平时与期末分项,并安排针对性答疑。`
|
||||
if (Number(value.averageScore) < 70) return `平均分为 ${score(value.averageScore)},建议检查易失分知识点与教学进度,结合分数段安排补强。`
|
||||
if (Number(value.standardDeviation) > 20) return `成绩离散度较高,建议关注不同教学班或学生群体的学习差异,核对评价标准与教学支持。`
|
||||
return `平均分 ${score(value.averageScore)}、合格率 ${score(value.passRate)}%,当前表现稳定;可重点关注低分段学生的持续跟进。`
|
||||
})
|
||||
|
||||
function score(value: unknown) {
|
||||
return Number(value).toFixed(1)
|
||||
@@ -210,6 +218,7 @@ onBeforeUnmount(() => {
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="recommendation" class="action-advice"><span>ACTION ADVICE</span><p>{{ recommendation }}</p></section>
|
||||
<el-empty v-else-if="!loading" description="暂无可展示的课程统计" />
|
||||
|
||||
<section v-if="rows.length" class="chart-panel">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import http from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import StudentDashboardView from './StudentDashboardView.vue'
|
||||
import TeacherDashboardView from './TeacherDashboardView.vue'
|
||||
|
||||
interface DashboardData {
|
||||
audience: {
|
||||
@@ -57,9 +58,24 @@ interface DashboardData {
|
||||
classroomReservations: number
|
||||
generalApprovals: number
|
||||
}
|
||||
greeting: DashboardGreeting
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
interface DashboardGreeting {
|
||||
role: string
|
||||
title: string
|
||||
subtitle: string
|
||||
label: string
|
||||
narrative: string
|
||||
insights: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
tone: 'calm' | 'positive' | 'attention'
|
||||
}>
|
||||
}
|
||||
|
||||
interface DashboardLink {
|
||||
key: string
|
||||
label: string
|
||||
@@ -68,24 +84,46 @@ interface DashboardLink {
|
||||
icon: Component
|
||||
}
|
||||
|
||||
interface WarningRecord {
|
||||
id: string
|
||||
studentName: string
|
||||
studentNumber: string
|
||||
className: string
|
||||
status: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const roles = computed(() => auth.user?.roles ?? [])
|
||||
const isStudentOverview = computed(() =>
|
||||
roles.value.includes('Student') &&
|
||||
!roles.value.some((role) =>
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role),
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role),
|
||||
),
|
||||
)
|
||||
const isTeacherOverview = computed(() =>
|
||||
roles.value.includes('Teacher') &&
|
||||
!roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role)),
|
||||
)
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const data = ref<DashboardData | null>(null)
|
||||
const counselorWarnings = ref<WarningRecord[]>([])
|
||||
const now = ref(new Date())
|
||||
|
||||
function hasRole(...allowedRoles: string[]) {
|
||||
return roles.value.some((role) => allowedRoles.includes(role))
|
||||
}
|
||||
|
||||
const isCounselorDashboard = computed(() =>
|
||||
hasRole('Counselor') && !hasRole('SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'),
|
||||
)
|
||||
|
||||
const activeCounselorWarnings = computed(() =>
|
||||
counselorWarnings.value.filter((warning) => warning.status === 1),
|
||||
)
|
||||
|
||||
function parseDateOnly(value?: string) {
|
||||
if (!value) return null
|
||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||
@@ -342,6 +380,19 @@ const readiness = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const operationAlerts = computed(() => {
|
||||
const counts = data.value?.counts
|
||||
if (!counts) return []
|
||||
const alerts = [] as Array<{ level: 'critical' | 'warning'; title: string; detail: string; route: string }>
|
||||
const unpublished = counts.teachingTasks - counts.publishedTeachingTasks
|
||||
const unscheduled = counts.publishedTeachingTasks - counts.scheduledTeachingTasks
|
||||
if (unpublished > 0) alerts.push({ level: 'warning', title: '教学任务尚未发布', detail: `${unpublished} 个教学班尚未发布,后续排课与选课无法推进。`, route: '/teaching-tasks' })
|
||||
if (unscheduled > 0) alerts.push({ level: 'critical', title: '课表覆盖存在缺口', detail: `${unscheduled} 个已发布教学班尚未进入课表。`, route: hasRole('SuperAdmin', 'AcademicAdmin') ? '/schedules' : '/class-timetable' })
|
||||
if (counts.submittedGradeSheets > 0) alerts.push({ level: 'warning', title: '成绩审核等待处理', detail: `${counts.submittedGradeSheets} 张成绩登记册已提交,等待审核或发布。`, route: '/grades' })
|
||||
if (counts.openCourseSelectionRounds > 0 && counts.courseEnrollments === 0) alerts.push({ level: 'warning', title: '开放选课尚无有效记录', detail: `${counts.openCourseSelectionRounds} 个选课批次开放中,但当前未发现有效选课。`, route: '/course-selections' })
|
||||
return alerts
|
||||
})
|
||||
|
||||
const primaryActionRoute = computed(() =>
|
||||
todoItems.value[0]?.route ?? quickActions.value[0]?.route ?? '/notifications',
|
||||
)
|
||||
@@ -351,6 +402,11 @@ async function loadDashboard() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
data.value = (await http.get<DashboardData>('/dashboard')).data
|
||||
if (isCounselorDashboard.value) {
|
||||
counselorWarnings.value = (await http.get<WarningRecord[]>('/warnings/records', {
|
||||
params: { academicTermId: data.value.currentTerm?.id },
|
||||
})).data
|
||||
}
|
||||
} catch {
|
||||
loadError.value = '教务总览暂时无法加载,请检查服务连接后重试。'
|
||||
} finally {
|
||||
@@ -359,7 +415,7 @@ async function loadDashboard() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isStudentOverview.value) {
|
||||
if (isStudentOverview.value || isTeacherOverview.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
@@ -369,6 +425,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<StudentDashboardView v-if="isStudentOverview" />
|
||||
<TeacherDashboardView v-else-if="isTeacherOverview" />
|
||||
|
||||
<div v-else v-loading="loading" class="admin-dashboard">
|
||||
<el-result
|
||||
@@ -390,8 +447,8 @@ onMounted(async () => {
|
||||
<i>数据范围</i>
|
||||
</div>
|
||||
<p class="overview-eyebrow">ACADEMIC OPERATIONS</p>
|
||||
<h1>{{ data.audience.title }}</h1>
|
||||
<p class="overview-description">{{ data.audience.description }}</p>
|
||||
<h1>{{ data.greeting.title }}</h1>
|
||||
<p class="overview-description">{{ data.greeting.subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<button class="pending-brief" type="button" @click="router.push(primaryActionRoute)">
|
||||
@@ -436,6 +493,23 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="greeting-insights" :aria-label="data.greeting.label">
|
||||
<span class="greeting-insights-label">{{ data.greeting.label }}</span>
|
||||
<div>
|
||||
<article
|
||||
v-for="insight in data.greeting.insights"
|
||||
:key="insight.label"
|
||||
:class="`greeting-insight ${insight.tone}`"
|
||||
>
|
||||
<span>{{ insight.label }}</span>
|
||||
<strong>{{ insight.value }}</strong>
|
||||
<small>{{ insight.hint }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="greeting-narrative">{{ data.greeting.narrative }}</p>
|
||||
|
||||
<section class="overview-metrics" aria-label="关键教学数据">
|
||||
<button
|
||||
v-for="metric in adminMetrics"
|
||||
@@ -450,7 +524,38 @@ onMounted(async () => {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="operation-alerts">
|
||||
<header><div><span>OPERATION WATCH</span><h2>运行预警</h2></div><small>{{ operationAlerts.length ? `${operationAlerts.length} 个运行断点需要关注` : '当前未发现运行断点' }}</small></header>
|
||||
<div v-if="operationAlerts.length" class="operation-alert-list">
|
||||
<button v-for="alert in operationAlerts" :key="alert.title" :class="alert.level" @click="router.push(alert.route)"><b>{{ alert.title }}</b><span>{{ alert.detail }}</span><i>立即处理 →</i></button>
|
||||
</div>
|
||||
<p v-else>教学任务、课表与成绩流程当前衔接正常。</p>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-work-grid">
|
||||
<article v-if="isCounselorDashboard" class="dashboard-panel counselor-radar">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<span class="panel-index">RADAR / COUNSELOR</span>
|
||||
<h2>需重点关注的学生</h2>
|
||||
<p>{{ activeCounselorWarnings.length ? `当前有 ${activeCounselorWarnings.length} 条生效预警,按最新记录展示。` : '当前没有生效中的学业预警。' }}</p>
|
||||
</div>
|
||||
<button type="button" class="radar-link" @click="router.push('/warnings')">完整预警 →</button>
|
||||
</header>
|
||||
<div v-if="activeCounselorWarnings.length" class="counselor-risk-list">
|
||||
<button v-for="warning in activeCounselorWarnings.slice(0, 4)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||
<span>{{ warning.className }}</span>
|
||||
<b>{{ warning.studentName }}</b>
|
||||
<small>{{ warning.detail }}</small>
|
||||
<i>查看 →</i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="todo-empty">
|
||||
<el-icon><Checked /></el-icon>
|
||||
<div><b>当前没有重点关注学生</b><span>新的学业预警会自动出现在这里。</span></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="dashboard-panel todo-panel">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
@@ -586,6 +691,62 @@ onMounted(async () => {
|
||||
background-size: 32px 32px, 32px 32px, auto;
|
||||
}
|
||||
|
||||
.greeting-insights {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
padding: 17px 21px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
.greeting-insights-label {
|
||||
align-self: center;
|
||||
color: var(--dashboard-teal);
|
||||
font: 700 10px/1.5 Consolas, monospace;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
|
||||
.greeting-insights > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.greeting-insight {
|
||||
min-width: 0;
|
||||
padding-left: 13px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-left: 2px solid #a9bcc6;
|
||||
}
|
||||
|
||||
.greeting-insight.positive { border-color: var(--dashboard-teal); }
|
||||
.greeting-insight.attention { border-color: var(--dashboard-amber); }
|
||||
.greeting-insight span { color: #687788; font-size: 11px; }
|
||||
.greeting-insight strong { color: var(--dashboard-navy); font-size: 20px; }
|
||||
.greeting-insight small { overflow: hidden; color: #84909d; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.greeting-narrative {
|
||||
margin: -7px 0 0;
|
||||
padding: 0 3px;
|
||||
color: #526078;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.operation-alerts { padding: 21px 24px; border: 1px solid #e5e8ed; background: #fff; }
|
||||
.operation-alerts header { display: flex; justify-content: space-between; gap: 16px; align-items: end; }
|
||||
.operation-alerts header span { color: var(--dashboard-teal); font: 700 10px/1 Consolas,monospace; letter-spacing: .12em; }
|
||||
.operation-alerts h2 { margin: 7px 0 0; color: var(--dashboard-navy); font-size: 19px; }
|
||||
.operation-alerts header small { color: #697789; font-size: 11px; }
|
||||
.operation-alert-list { margin-top: 16px; display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; }
|
||||
.operation-alert-list button { padding: 14px 15px; display: grid; gap: 5px; text-align: left; border: 1px solid #e8e1d3; border-left: 3px solid var(--dashboard-amber); background: #fffcf6; }
|
||||
.operation-alert-list button.critical { border-left-color: #ba5145; background: #fff9f8; }
|
||||
.operation-alert-list b { color: #334254; font-size: 13px; }.operation-alert-list span { color: #6d7888; font-size: 11px; line-height: 1.5; }.operation-alert-list i { color: #8b6d3a; font-size: 11px; font-style: normal; }
|
||||
.operation-alerts > p { margin: 15px 0 0; color: #627184; font-size: 12px; }
|
||||
|
||||
.overview-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -866,6 +1027,28 @@ onMounted(async () => {
|
||||
background: var(--dashboard-paper);
|
||||
}
|
||||
|
||||
.counselor-radar { border-top: 3px solid var(--dashboard-amber); }
|
||||
.radar-link { padding: 5px 0; border: 0; color: #806136; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||
.radar-link:hover { color: var(--dashboard-blue); }
|
||||
.counselor-risk-list { display: grid; }
|
||||
.counselor-risk-list button {
|
||||
padding: 13px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 76px 68px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #eee8dc;
|
||||
background: transparent;
|
||||
}
|
||||
.counselor-risk-list button:last-child { border-bottom: 0; }
|
||||
.counselor-risk-list button:hover b { color: var(--dashboard-blue); }
|
||||
.counselor-risk-list span { color: #987337; font: 700 9px/1.3 Consolas, monospace; letter-spacing: .04em; }
|
||||
.counselor-risk-list b { color: #3a4758; font-size: 13px; }
|
||||
.counselor-risk-list small { overflow: hidden; color: #6c7788; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.counselor-risk-list i { color: #8d7042; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||
|
||||
.todo-panel,
|
||||
.quick-panel,
|
||||
.readiness-panel {
|
||||
@@ -1155,6 +1338,7 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.greeting-insights { grid-template-columns: 1fr; gap: 12px; }
|
||||
.dashboard-work-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1170,6 +1354,13 @@ button:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.operation-alerts { padding: 18px; }
|
||||
.operation-alert-list { grid-template-columns: 1fr; }
|
||||
.greeting-insights { padding: 15px; }
|
||||
.greeting-insights > div { grid-template-columns: 1fr; }
|
||||
.counselor-risk-list button { grid-template-columns: 1fr auto; }
|
||||
.counselor-risk-list span { grid-column: 1 / -1; }
|
||||
.counselor-risk-list small { white-space: normal; }
|
||||
.admin-dashboard { gap: 10px; }
|
||||
|
||||
.overview-hero {
|
||||
|
||||
@@ -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>
|
||||
@@ -2,14 +2,17 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
EditPen,
|
||||
Plus,
|
||||
Promotion,
|
||||
Refresh,
|
||||
Setting,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
academicTermLabel,
|
||||
@@ -33,8 +36,8 @@ const collegeId = ref('')
|
||||
const projectKeyword = ref('')
|
||||
const projectPage = ref(1)
|
||||
const projectPageSize = ref(20)
|
||||
const projectTotal = ref(0)
|
||||
const projects = ref<any[]>([])
|
||||
const courseTotal = ref(0)
|
||||
const courses = ref<any[]>([])
|
||||
const studentResults = ref<any[]>([])
|
||||
const detailDrawer = ref(false)
|
||||
const detail = ref<any | null>(null)
|
||||
@@ -44,6 +47,7 @@ const recordKeyword = ref('')
|
||||
const schemeDialog = ref(false)
|
||||
const editingScheme = ref(false)
|
||||
const schemeProject = ref<any | null>(null)
|
||||
const importFileInput = ref<HTMLInputElement>()
|
||||
|
||||
const schemeForm = reactive({
|
||||
contributionWeight: 1,
|
||||
@@ -86,6 +90,18 @@ const modeLabels: Record<string, string> = {
|
||||
Centralized: '集中安排',
|
||||
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(() =>
|
||||
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
|
||||
@@ -196,8 +212,8 @@ async function load() {
|
||||
pageSize: projectPageSize.value,
|
||||
},
|
||||
})).data
|
||||
projects.value = data.items
|
||||
projectTotal.value = data.total
|
||||
courses.value = data.items
|
||||
courseTotal.value = data.total
|
||||
projectPage.value = data.page
|
||||
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() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
@@ -528,45 +576,52 @@ onMounted(async () => {
|
||||
<el-button type="primary" @click="projectPage = 1; load()">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="grade-project-list">
|
||||
<article v-for="project in projects" :key="project.id" class="grade-project-card">
|
||||
<div class="project-mark">
|
||||
<span>{{ project.code }}</span>
|
||||
<b>{{ modeLabels[project.arrangementMode] }}</b>
|
||||
</div>
|
||||
<div class="project-summary">
|
||||
<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>
|
||||
<section v-loading="loading" class="grade-course-list">
|
||||
<article v-for="course in courses" :key="course.teachingTaskId" class="grade-course-card">
|
||||
<header class="grade-course-head">
|
||||
<div>
|
||||
<span>{{ course.termName }} · {{ course.taskNumber }}</span>
|
||||
<h3>{{ course.courseCode }} · {{ course.courseName }}</h3>
|
||||
<small>{{ course.teacherNames.join('、') || '教师待定' }} · {{ course.collegeName }}</small>
|
||||
</div>
|
||||
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)">
|
||||
打开成绩单
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="sheet-empty">
|
||||
<b>尚未建立实验成绩单</b>
|
||||
<span>建立时会按集中名单或有效预约生成评分对象。</span>
|
||||
<div class="course-rollup">
|
||||
<b>{{ course.projectCount }}</b><span>个实验项目</span>
|
||||
<small v-if="course.sheetCount">{{ course.sheetCount }} 份成绩单 · {{ course.scoredCount }}/{{ course.studentCount }} 已计分</small>
|
||||
<small v-else>尚未建立成绩单</small>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate(project)">
|
||||
建立成绩单
|
||||
</el-button>
|
||||
</template>
|
||||
</header>
|
||||
<el-table :data="course.projects" size="small" class="course-project-table">
|
||||
<el-table-column label="实验项目" min-width="260">
|
||||
<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>
|
||||
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
|
||||
<el-empty v-if="!loading && !courses.length" description="当前筛选条件下没有可评分实验课程" />
|
||||
</section>
|
||||
<el-pagination
|
||||
v-model:current-page="projectPage"
|
||||
v-model:page-size="projectPageSize"
|
||||
:total="projectTotal"
|
||||
:total="courseTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@@ -637,6 +692,7 @@ onMounted(async () => {
|
||||
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
|
||||
<h3>{{ detail.projectName }}</h3>
|
||||
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
|
||||
<p class="project-schedule">{{ projectScheduleLabel(detail) }}</p>
|
||||
</div>
|
||||
<div class="workbench-status">
|
||||
<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()"
|
||||
/>
|
||||
|
||||
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
|
||||
|
||||
<footer class="workbench-actions">
|
||||
<div>
|
||||
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
|
||||
<span>开课学院:{{ detail.courseCollegeName }}</span>
|
||||
</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" type="primary" :icon="Promotion" @click="submitSheet">提交审核</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-input { width: min(320px, 100%); }
|
||||
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||
.grade-project-list { display: grid; gap: 12px; 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%); }
|
||||
.project-mark { display: grid; gap: 7px; }
|
||||
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; }
|
||||
.project-mark b { color: var(--grade-blue); font-size: 12px; }
|
||||
.project-summary { min-width: 0; }
|
||||
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; }
|
||||
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; }
|
||||
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
|
||||
.sheet-progress b { color: var(--grade-teal); }
|
||||
.sheet-empty b { color: #5d7480; }
|
||||
.grade-course-list { display: grid; gap: 10px; min-height: 180px; }
|
||||
.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%); }
|
||||
.grade-course-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: #f3f7f8; }
|
||||
.grade-course-head > div:first-child { min-width: 0; }
|
||||
.grade-course-head span { color: #607b88; font: 700 10px/1.3 Consolas, monospace; }
|
||||
.grade-course-head h3 { margin: 3px 0; color: var(--grade-ink); font-size: 15px; }
|
||||
.grade-course-head small { display: block; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.course-rollup { display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: 5px; flex: none; text-align: right; }
|
||||
.course-rollup b { color: var(--grade-teal); font: 750 22px/1 Consolas, monospace; }
|
||||
.course-rollup > span { color: #55707b; font: 600 11px/1.2 inherit; }
|
||||
.course-rollup small { grid-column: 1 / -1; margin-top: 3px; color: #70858d; font-size: 10px; }
|
||||
.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-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; }
|
||||
@@ -902,9 +968,7 @@ onMounted(async () => {
|
||||
.workbench-actions b { color: #a24f36; font-size: 12px; }
|
||||
.workbench-actions span { color: var(--muted); font-size: 12px; }
|
||||
@media (max-width: 980px) {
|
||||
.grade-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; }
|
||||
.sheet-progress, .sheet-empty { grid-column: 2; }
|
||||
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
|
||||
.grade-course-head { align-items: flex-start; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.assessment-flow { grid-template-columns: 1fr; }
|
||||
@@ -912,8 +976,8 @@ onMounted(async () => {
|
||||
.grade-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.grade-toolbar .el-select, .grade-toolbar .el-input { width: 100%; }
|
||||
.grade-toolbar > span { margin-left: 0; }
|
||||
.grade-project-card { grid-template-columns: 1fr; }
|
||||
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
|
||||
.grade-course-head { align-items: stretch; flex-direction: column; }
|
||||
.course-rollup { align-self: flex-start; text-align: left; }
|
||||
.student-result-grid { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
|
||||
@@ -37,6 +37,7 @@ const taskKeyword = ref('')
|
||||
|
||||
const projectDialog = ref(false)
|
||||
const editingProjectId = ref('')
|
||||
const correctingPublishedProject = ref(false)
|
||||
const projectForm = reactive({
|
||||
teachingTaskIds: [] as string[],
|
||||
scheduleEntryId: '',
|
||||
@@ -158,6 +159,7 @@ const statusLabels: Record<string, string> = {
|
||||
|
||||
function resetProjectForm() {
|
||||
editingProjectId.value = ''
|
||||
correctingPublishedProject.value = false
|
||||
Object.assign(projectForm, {
|
||||
teachingTaskIds: [],
|
||||
scheduleEntryId: '',
|
||||
@@ -197,6 +199,7 @@ function openCreateProject() {
|
||||
|
||||
function openEditProject(project: any) {
|
||||
editingProjectId.value = project.id
|
||||
correctingPublishedProject.value = false
|
||||
Object.assign(projectForm, {
|
||||
teachingTaskIds: [project.teachingTaskId],
|
||||
scheduleEntryId: project.scheduleEntryId ?? '',
|
||||
@@ -212,18 +215,23 @@ function openEditProject(project: any) {
|
||||
projectDialog.value = true
|
||||
}
|
||||
|
||||
function openCorrectPublishedProject(project: any) {
|
||||
openEditProject(project)
|
||||
correctingPublishedProject.value = true
|
||||
}
|
||||
|
||||
function projectNameLines(value: string) {
|
||||
return value.split(/\r?\n/).map((name) => name.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
const names = projectNameLines(projectForm.projectNames)
|
||||
const hasValidName = editingProjectId.value
|
||||
const hasValidName = correctingPublishedProject.value || editingProjectId.value
|
||||
? !!projectForm.name.trim()
|
||||
: projectForm.nameMode === 'InputNames'
|
||||
? names.length > 0
|
||||
: !!projectForm.name.trim()
|
||||
if (!projectForm.teachingTaskIds.length ||
|
||||
if ((!correctingPublishedProject.value && !projectForm.teachingTaskIds.length) ||
|
||||
(editingProjectId.value && projectForm.arrangementMode === 'Centralized' && !projectForm.scheduleEntryId) ||
|
||||
!projectForm.code.trim()
|
||||
|| !hasValidName || projectForm.dates.length !== 2) {
|
||||
@@ -244,7 +252,14 @@ async function saveProject() {
|
||||
endDate: projectForm.dates[1],
|
||||
}
|
||||
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)
|
||||
ElMessage.success('实验项目已更新')
|
||||
} else {
|
||||
@@ -842,7 +857,10 @@ onMounted(async () => {
|
||||
@click="publishProject(row)"
|
||||
>发布</el-button>
|
||||
</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
|
||||
v-if="row.status !== 'Closed' && row.arrangementMode === 'SelfScheduled'"
|
||||
size="small"
|
||||
@@ -952,11 +970,17 @@ onMounted(async () => {
|
||||
|
||||
<el-dialog
|
||||
v-model="projectDialog"
|
||||
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||
:title="correctingPublishedProject ? '修正已发布实验项目内容' : editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
|
||||
width="720px"
|
||||
top="5vh"
|
||||
>
|
||||
<el-form label-position="top" class="experiment-form">
|
||||
<el-alert
|
||||
v-if="correctingPublishedProject"
|
||||
title="已发布项目仅可修正名称、实验内容和到场要求;编码、安排方式、课表绑定及开放日期保持不变。保存后将通知相关学生。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="form-section">
|
||||
<header><span>PROJECT</span><b>规定实验项目</b></header>
|
||||
<el-form-item v-if="editingProjectId && projectForm.arrangementMode === 'Centralized'" label="已发布课表中的实验课" required>
|
||||
@@ -1027,7 +1051,7 @@ onMounted(async () => {
|
||||
</el-form-item>
|
||||
<div class="form-grid two">
|
||||
<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 v-if="editingProjectId" label="项目名称" required>
|
||||
<el-input v-model="projectForm.name" maxlength="120" placeholder="如 数据库事务与并发实验" />
|
||||
@@ -1058,7 +1082,7 @@ onMounted(async () => {
|
||||
|
||||
<div class="form-section">
|
||||
<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">
|
||||
<b>集中安排</b><small>复用已发布课表的实验课</small>
|
||||
</el-radio-button>
|
||||
@@ -1069,6 +1093,7 @@ onMounted(async () => {
|
||||
<el-form-item label="项目开放日期" required>
|
||||
<el-date-picker
|
||||
v-model="projectForm.dates"
|
||||
:disabled="correctingPublishedProject"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="至"
|
||||
@@ -1105,7 +1130,9 @@ onMounted(async () => {
|
||||
<template #footer>
|
||||
<el-button @click="projectDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProject">
|
||||
{{ editingProjectId
|
||||
{{ correctingPublishedProject
|
||||
? '保存修正'
|
||||
: editingProjectId
|
||||
? '保存项目'
|
||||
: projectForm.teachingTaskIds.length
|
||||
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
|
||||
|
||||
@@ -58,6 +58,7 @@ const unreadCount = ref(0)
|
||||
const total = ref(0)
|
||||
const sentTotal = ref(0)
|
||||
const loading = ref(false)
|
||||
const markingAllRead = ref(false)
|
||||
const sending = ref(false)
|
||||
const composerLoading = ref(false)
|
||||
const recipientLoading = ref(false)
|
||||
@@ -380,6 +381,8 @@ async function openNotification(notification: any) {
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (markingAllRead.value) return
|
||||
markingAllRead.value = true
|
||||
try {
|
||||
await http.post('/notifications/read-all')
|
||||
notifications.value.forEach(notification => {
|
||||
@@ -389,6 +392,8 @@ async function markAllRead() {
|
||||
ElMessage.success('全部消息已标为已读')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
markingAllRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +546,13 @@ onMounted(() => load())
|
||||
@clear="load(true)"
|
||||
/>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -807,6 +807,16 @@ function changeEntryKind() {
|
||||
}
|
||||
}
|
||||
|
||||
async function preflightSchedule() {
|
||||
try {
|
||||
const { data } = await http.get(`/schedules/plans/${selected.value.id}/preflight`)
|
||||
const message = data.unscheduledTasks
|
||||
? `尚有 ${data.unscheduledTasks} 个教学班未安排:\n${data.messages.join('\n')}`
|
||||
: '当前草稿已覆盖全部需要排课的教学班。'
|
||||
await ElMessageBox.alert(message, `排课前检查 · 已覆盖 ${data.scheduledTasks}/${data.totalTasks}`, { confirmButtonText: '知道了' })
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
if (!entryForm.teachingTaskId ||
|
||||
((entryForm.kind === 'Experiment' ||
|
||||
@@ -937,6 +947,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
复制调整
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
:disabled="scheduleJobLoading"
|
||||
@click="preflightSchedule"
|
||||
>
|
||||
排课前检查
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDraft"
|
||||
type="warning"
|
||||
|
||||
@@ -70,6 +70,27 @@ interface GradeItem {
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
interface DashboardGreeting {
|
||||
title: string
|
||||
subtitle: string
|
||||
label: string
|
||||
narrative: string
|
||||
insights: Array<{
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
tone: 'calm' | 'positive' | 'attention'
|
||||
}>
|
||||
}
|
||||
|
||||
interface WarningItem {
|
||||
id: string
|
||||
type: number
|
||||
status: number
|
||||
detail: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
@@ -79,6 +100,9 @@ const notifications = ref<NotificationItem[]>([])
|
||||
const unreadCount = ref(0)
|
||||
const exams = ref<ExamItem[]>([])
|
||||
const grades = ref<GradeItem[]>([])
|
||||
const dashboardGreeting = ref<DashboardGreeting | null>(null)
|
||||
const warnings = ref<WarningItem[]>([])
|
||||
const graduation = ref<any>(null)
|
||||
const now = ref(new Date())
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
@@ -159,6 +183,11 @@ const recentGrades = computed(() =>
|
||||
.slice(0, 4),
|
||||
)
|
||||
|
||||
const activeWarnings = computed(() => warnings.value.filter((warning) => warning.status === 1))
|
||||
const radarSummary = computed(() => activeWarnings.value.length
|
||||
? `发现 ${activeWarnings.value.length} 项需要你关注的学习风险,建议优先处理下方提示。`
|
||||
: '系统暂未发现需要你处理的学业风险,继续保持当前学习节奏。')
|
||||
|
||||
function parseDateOnly(value?: string) {
|
||||
if (!value) return null
|
||||
const [year, month, day] = value.slice(0, 10).split('-').map(Number)
|
||||
@@ -281,6 +310,9 @@ async function loadOverview() {
|
||||
http.get('/notifications', { params: { page: 1, pageSize: 5 } }),
|
||||
http.get('/exams/my-schedule'),
|
||||
http.get('/grades/student/transcript'),
|
||||
http.get<DashboardGreeting>('/dashboard/greeting'),
|
||||
http.get<WarningItem[]>('/warnings/my-warnings'),
|
||||
http.get('/student/academic-planning'),
|
||||
])
|
||||
|
||||
if (results[0].status === 'fulfilled') {
|
||||
@@ -304,6 +336,15 @@ async function loadOverview() {
|
||||
} else {
|
||||
failedSections.value.push('考试成绩')
|
||||
}
|
||||
if (results[4].status === 'fulfilled') {
|
||||
dashboardGreeting.value = results[4].value.data
|
||||
}
|
||||
if (results[5].status === 'fulfilled') {
|
||||
warnings.value = results[5].value.data
|
||||
} else {
|
||||
failedSections.value.push('学业风险雷达')
|
||||
}
|
||||
if (results[6].status === 'fulfilled') graduation.value = results[6].value.data
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -315,8 +356,8 @@ onMounted(loadOverview)
|
||||
<section class="student-overview-hero">
|
||||
<div class="student-hero-copy">
|
||||
<span class="section-kicker">MY ACADEMIC DAY</span>
|
||||
<h2>{{ greeting }},{{ auth.user?.displayName ?? '同学' }}</h2>
|
||||
<p>{{ todayLabel }}<template v-if="timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
<h2>{{ dashboardGreeting?.title ?? `${greeting},${auth.user?.displayName ?? '同学'}` }}</h2>
|
||||
<p>{{ dashboardGreeting?.subtitle ?? todayLabel }}<template v-if="!dashboardGreeting && timetable?.term"> · {{ timetable.term.name }}</template></p>
|
||||
</div>
|
||||
<div class="today-status">
|
||||
<span>今日课程</span>
|
||||
@@ -325,6 +366,39 @@ onMounted(loadOverview)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="dashboardGreeting?.insights.length" class="student-greeting-insights" :aria-label="dashboardGreeting.label">
|
||||
<span>{{ dashboardGreeting.label }}</span>
|
||||
<article v-for="insight in dashboardGreeting.insights" :key="insight.label" :class="insight.tone">
|
||||
<small>{{ insight.label }}</small>
|
||||
<strong>{{ insight.value }}</strong>
|
||||
<em>{{ insight.hint }}</em>
|
||||
</article>
|
||||
</section>
|
||||
<p v-if="dashboardGreeting?.narrative" class="student-greeting-narrative">{{ dashboardGreeting.narrative }}</p>
|
||||
|
||||
<section class="student-risk-radar" :class="{ attention: activeWarnings.length }">
|
||||
<header>
|
||||
<div>
|
||||
<span class="panel-kicker">ACADEMIC RADAR</span>
|
||||
<h3>学业风险雷达</h3>
|
||||
</div>
|
||||
<button type="button" @click="router.push('/warnings')">查看全部 <el-icon><ArrowRight /></el-icon></button>
|
||||
</header>
|
||||
<p>{{ radarSummary }}</p>
|
||||
<div v-if="activeWarnings.length" class="risk-list">
|
||||
<button v-for="warning in activeWarnings.slice(0, 3)" :key="warning.id" type="button" @click="router.push('/warnings')">
|
||||
<span>{{ categoryLabels.Warning }}</span>
|
||||
<strong>{{ warning.detail }}</strong>
|
||||
<i>去处理 →</i>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="graduation?.baseline" class="graduation-nav">
|
||||
<div><span class="panel-kicker">GRADUATION NAVIGATION</span><h3>毕业导航</h3><p>已完成 {{ graduation.baseline.planCompletedCredits ?? 0 }} 学分,距离培养方案要求还差 {{ graduation.baseline.creditGap ?? 0 }} 学分。</p></div>
|
||||
<button type="button" @click="router.push('/academic-planning')">查看毕业航线 <el-icon><ArrowRight /></el-icon></button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="failedSections.length"
|
||||
type="warning"
|
||||
@@ -539,6 +613,63 @@ onMounted(loadOverview)
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.student-greeting-insights {
|
||||
padding: 15px 21px;
|
||||
display: grid;
|
||||
grid-template-columns: 105px repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #f7faf9;
|
||||
}
|
||||
|
||||
.student-greeting-insights > span {
|
||||
align-self: center;
|
||||
color: var(--teal);
|
||||
font: 700 10px/1.5 Consolas, monospace;
|
||||
letter-spacing: .11em;
|
||||
}
|
||||
|
||||
.student-greeting-insights article {
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
border-left: 2px solid #a9bcc6;
|
||||
}
|
||||
|
||||
.student-greeting-insights article.positive { border-color: var(--teal); }
|
||||
.student-greeting-insights article.attention { border-color: #c4812a; }
|
||||
.student-greeting-insights small { color: #667488; font-size: 10px; }
|
||||
.student-greeting-insights strong { color: var(--ink); font-size: 19px; }
|
||||
.student-greeting-insights em { overflow: hidden; color: var(--muted); font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.student-greeting-narrative {
|
||||
margin: -5px 0 0;
|
||||
padding: 0 3px;
|
||||
color: #526078;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.student-risk-radar {
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #dce6eb;
|
||||
background: #fbfdfd;
|
||||
}
|
||||
.graduation-nav{padding:19px 22px;display:flex;justify-content:space-between;align-items:center;gap:18px;border:1px solid #dce6eb;background:#f7faf9}.graduation-nav h3{margin:7px 0;color:var(--ink);font-size:17px}.graduation-nav p{margin:0;color:#59677a;font-size:12px;line-height:1.6}.graduation-nav button{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--indigo);font-size:12px;white-space:nowrap}
|
||||
|
||||
.student-risk-radar.attention { border-left: 3px solid #c4812a; background: #fffcf6; }
|
||||
.student-risk-radar header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.student-risk-radar h3 { margin: 6px 0 0; color: var(--ink); font-size: 17px; }
|
||||
.student-risk-radar header button { padding: 4px 0; display: inline-flex; align-items: center; gap: 4px; border: 0; color: #526078; background: transparent; font-size: 12px; white-space: nowrap; }
|
||||
.student-risk-radar header button:hover { color: var(--indigo); }
|
||||
.student-risk-radar > p { margin: 13px 0 0; color: #59677a; font-size: 12px; line-height: 1.7; }
|
||||
.risk-list { margin-top: 13px; display: grid; }
|
||||
.risk-list button { padding: 12px 0; display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 10px; text-align: left; border: 0; border-top: 1px solid #eee6d8; background: transparent; }
|
||||
.risk-list span { align-self: center; color: #a16b1c; font: 700 9px/1.4 Consolas, monospace; letter-spacing: .06em; }
|
||||
.risk-list strong { color: #38475a; font-size: 12px; font-weight: 600; line-height: 1.55; }
|
||||
.risk-list i { align-self: center; color: #9a743d; font-size: 11px; font-style: normal; white-space: nowrap; }
|
||||
|
||||
.today-status {
|
||||
min-width: 180px;
|
||||
margin-left: auto;
|
||||
@@ -871,12 +1002,18 @@ onMounted(loadOverview)
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.student-greeting-insights { grid-template-columns: 1fr repeat(3, minmax(0, 1fr)); }
|
||||
.student-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.student-greeting-insights { padding: 15px 18px; grid-template-columns: 1fr; }
|
||||
.student-risk-radar { padding: 18px; }
|
||||
.graduation-nav{padding:18px;align-items:flex-start;flex-direction:column}
|
||||
.risk-list button { grid-template-columns: 1fr auto; }
|
||||
.risk-list span { grid-column: 1 / -1; }
|
||||
.student-overview {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ArrowRight, Calendar, DocumentChecked, Reading } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import http from '../api/http'
|
||||
|
||||
interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> }
|
||||
interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } }
|
||||
const router = useRouter()
|
||||
const greeting = ref<Greeting | null>(null)
|
||||
const sheets = ref<Sheet[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const statusLabel: Record<string, string> = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' }
|
||||
const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0
|
||||
const pendingSheets = computed(() => sheets.value.filter(x => x.sheet?.status === 'Draft' || x.sheet?.status === 'Returned'))
|
||||
async function load() {
|
||||
loading.value = true; failed.value = false
|
||||
try {
|
||||
const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard')
|
||||
greeting.value = dashboard.data.greeting
|
||||
const result = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } })
|
||||
sheets.value = result.data.items
|
||||
} catch { failed.value = true } finally { loading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="teacher-cockpit">
|
||||
<el-result v-if="failed" icon="warning" title="教学驾驶舱加载失败" sub-title="请检查服务连接后重新加载。"><template #extra><el-button type="primary" @click="load">重新加载</el-button></template></el-result>
|
||||
<template v-else-if="greeting">
|
||||
<section class="cockpit-hero">
|
||||
<span>TEACHING COCKPIT</span><h2>{{ greeting.title }}</h2><p>{{ greeting.subtitle }}</p><small>{{ greeting.narrative }}</small>
|
||||
</section>
|
||||
<section class="cockpit-metrics"><article v-for="item in greeting.insights" :key="item.label" :class="item.tone"><span>{{ item.label }}</span><strong>{{ item.value }}</strong><small>{{ item.hint }}</small></article></section>
|
||||
<section class="cockpit-grid">
|
||||
<article class="cockpit-panel">
|
||||
<header><div><span><el-icon><DocumentChecked /></el-icon> GRADE PROGRESS</span><h3>成绩登记进度</h3></div><button @click="router.push('/grades')">成绩管理 <el-icon><ArrowRight /></el-icon></button></header>
|
||||
<div v-if="pendingSheets.length" class="sheet-list"><button v-for="item in pendingSheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><div><b>{{ item.courseName }}</b><small>{{ item.classNames.join('、') || item.taskName }} · {{ statusLabel[item.sheet?.status ?? ''] ?? '待登记' }}</small></div><strong>{{ progress(item.sheet) }}%</strong></button></div>
|
||||
<div v-else class="cockpit-empty"><el-icon><DocumentChecked /></el-icon><b>当前没有待提交成绩</b><span>成绩登记册会在这里按优先级显示。</span></div>
|
||||
</article>
|
||||
<article class="cockpit-panel">
|
||||
<header><div><span><el-icon><Reading /></el-icon> TEACHING CLASSES</span><h3>本学期教学班</h3></div><button @click="router.push('/teaching-tasks')">教学任务 <el-icon><ArrowRight /></el-icon></button></header>
|
||||
<div v-if="sheets.length" class="class-list"><button v-for="item in sheets.slice(0, 5)" :key="item.id" @click="router.push('/grades')"><b>{{ item.courseName }}</b><span>{{ item.classNames.join('、') || item.taskName }}</span><small>{{ item.sheet ? `${item.sheet.studentCount} 人 · 已完成 ${item.sheet.completedCount} 人` : '尚未建立成绩登记册' }}</small></button></div>
|
||||
<div v-else class="cockpit-empty"><el-icon><Calendar /></el-icon><b>本学期暂未分配教学班</b><span>教学任务发布后会自动汇总到这里。</span></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.teacher-cockpit{display:grid;gap:18px}.cockpit-hero{padding:31px 35px;color:#fff;background:linear-gradient(120deg,#17284f,#244276 62%,#08726d);}.cockpit-hero>span,.cockpit-panel header>div>span{color:#71d9ca;font:700 10px/1 Consolas,monospace;letter-spacing:.12em}.cockpit-hero h2{margin:13px 0 8px;font:700 clamp(27px,3vw,38px)/1.2 "STZhongsong","Songti SC",serif}.cockpit-hero p{margin:0;color:#d1daf0}.cockpit-hero small{display:block;margin-top:15px;color:#aebee0;font-size:12px}.cockpit-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cockpit-metrics article{padding:17px;border-left:3px solid #a9bcc6;background:#f7faf9;display:grid;gap:4px}.cockpit-metrics .positive{border-color:#098174}.cockpit-metrics .attention{border-color:#ce8b2c}.cockpit-metrics span,.cockpit-metrics small{color:#657488;font-size:11px}.cockpit-metrics strong{color:#17284f;font-size:23px}.cockpit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.cockpit-panel{padding:23px 26px;border:1px solid #e2e7ec;background:#fff}.cockpit-panel header{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid #e9edf0}.cockpit-panel h3{margin:8px 0 16px;color:#263648;font-size:18px}.cockpit-panel header button{border:0;background:transparent;color:#526078;font-size:12px;white-space:nowrap}.cockpit-panel button{cursor:pointer}.sheet-list button,.class-list button{width:100%;padding:14px 0;display:flex;justify-content:space-between;gap:12px;text-align:left;border:0;border-bottom:1px solid #edf0f3;background:transparent}.sheet-list b,.class-list b{display:block;color:#2e3c4c;font-size:14px}.sheet-list small,.class-list span,.class-list small{display:block;margin-top:5px;color:#748093;font-size:11px}.sheet-list strong{align-self:center;color:#0b8175;font:700 18px Consolas,monospace}.cockpit-empty{min-height:170px;display:grid;place-content:center;justify-items:center;color:#8c96a5;gap:8px;text-align:center;font-size:12px}.cockpit-empty .el-icon{font-size:26px}.cockpit-empty b{color:#5e6c7e}@media(max-width:760px){.cockpit-hero{padding:25px 21px}.cockpit-metrics,.cockpit-grid{grid-template-columns:1fr}.cockpit-panel{padding:20px 18px}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user