9 Commits
49 changed files with 9554 additions and 363 deletions
+18
View File
@@ -36,6 +36,24 @@ Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048
# OpenTelemetry 默认收集 HTTP、运行时和数据库指标;配置 OTLP 地址后才会外发。
Observability__Enabled=true
Observability__ServiceName=jiaowu-api
Observability__SlowQueryThresholdMilliseconds=500
# 默认不记录完整 SQL,避免日志或追踪系统接触业务数据。
Observability__IncludeSqlText=false
Observability__MaximumSqlTextLength=2000
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
# OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20REPLACE_WITH_TOKEN
# 系统内“运维与审计 → 系统性能”从 Prometheus 只读查询汇总指标。
PerformanceReporting__Enabled=false
# PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
# PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
# PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
PerformanceReporting__CacheSeconds=30
PerformanceReporting__TimeoutSeconds=10
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
Operations__BackupDirectory=/var/lib/jiaowu/backups
Operations__BackupWarningHours=24
+53 -3
View File
@@ -322,6 +322,55 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
### OpenTelemetry 与慢查询定位
应用已接入 OpenTelemetry 的 ASP.NET Core、HttpClient、.NET Runtime 指标,并通过
`Jiaowu.Api.Database` ActivitySource 和 Meter 记录 EF Core 数据库命令。配置
`OTEL_EXPORTER_OTLP_ENDPOINT` 后才启动 OpenTelemetry SDK 并向 OTLP Collector 外发;
未配置时不会创建无处消费的请求 Span,也不会尝试连接本地 Collector,结构化慢查询日志
仍然有效。
```text
Observability__Enabled=true
Observability__ServiceName=jiaowu-api
Observability__SlowQueryThresholdMilliseconds=500
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.edu.cn:4317
```
数据库指标包括 `jiaowu.db.command.duration``jiaowu.db.command.slow`
`jiaowu.db.command.failed`。为关键 EF 查询添加 `TagWith("模块.查询名")` 后,日志和
追踪会直接显示该稳定名称;无标签查询只显示操作类型和 SQL 模板哈希。默认
`Observability__IncludeSqlText=false`,不会把 SQL、参数值或连接串发送到日志和追踪
系统。仅在受控诊断窗口内临时启用完整 SQL 模板,并限制 Collector 权限与保留时间。
应用侧阈值用于关联接口、TraceId 和查询名称;生产 MySQL 还应由数据库管理员启用慢查询
日志,并将 `long_query_time` 设为与应用阈值一致。先按查询哈希/标签汇总高频慢查询,再
对脱敏后的 `SELECT` 在测试库或只读副本执行 `EXPLAIN ANALYZE`,根据实际扫描行数和循环
次数决定是否补组合索引或改写投影。`EXPLAIN ANALYZE` 会真实执行语句,不能直接用于生产
写操作。参考 [MySQL 慢查询日志](https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html)
和 [MySQL 8.4 EXPLAIN](https://dev.mysql.com/doc/refman/8.4/en/explain.html)。
OpenTelemetry Collector 将指标写入 Prometheus 后,超级管理员可直接在“组织与权限 →
运维与审计 → 系统性能”查看请求量、5xx 比例、HTTP/数据库 P95、慢查询趋势,以及最慢
接口和数据库查询排行。报表由 API 使用固定 PromQL 只读查询 Prometheus,浏览器不会
接触 Prometheus 地址或令牌;结果默认缓存 30 秒。原始 Trace 和更长时间范围仍建议在
Grafana 中下钻,配置其地址后页面会显示跳转入口。
```text
PerformanceReporting__Enabled=true
PerformanceReporting__PrometheusBaseUrl=https://prometheus.example.edu.cn/
PerformanceReporting__BearerToken=REPLACE_WITH_READ_ONLY_TOKEN
PerformanceReporting__GrafanaBaseUrl=https://grafana.example.edu.cn/
PerformanceReporting__CacheSeconds=30
PerformanceReporting__TimeoutSeconds=10
```
`PrometheusBaseUrl` 必须指向可访问 `/api/v1/query``/api/v1/query_range`
Prometheus 兼容接口,令牌应仅具有查询权限。未启用、未配置或指标源暂时不可用时,页面
会显示明确的空状态,不会改查业务数据库或拖慢正常请求。若 Collector/Prometheus 对
指标名或 `service_name` 标签做了转换,可通过 `PerformanceReporting` 下对应的
`*MetricName``ServiceNameLabel` 配置项适配,无需改前端。
### 后台任务与 RabbitMQ
自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与
@@ -368,9 +417,10 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
### 运维与审计控制台
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
超级管理员可从“组织与权限 → 运维与审计”查看系统性能,查询写操作日志、三类失败后台
任务、数据库、缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份
时效汇总出的异常告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖
前端菜单隐藏。
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump`
`mysql`(容器镜像已包含对应的 `mariadb-dump``mariadb` 客户端),并配置独立的
@@ -75,6 +75,7 @@ public sealed class ExperimentsController(
x.TaskNumber,
x.Name,
x.AcademicTermId,
x.CourseId,
TermName = x.AcademicTerm!.Name,
TermStartDate = x.AcademicTerm.StartDate,
TermEndDate = x.AcademicTerm.EndDate,
@@ -295,6 +296,78 @@ public sealed class ExperimentsController(
return Created(string.Empty, new { project.Id });
}
[HttpPost("batch")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateProjects(
ExperimentProjectBatchRequest request,
CancellationToken cancellationToken)
{
var taskIds = request.TeachingTaskIds
.Where(x => x != Guid.Empty)
.Distinct()
.ToList();
if (taskIds.Count == 0)
return ValidationProblem("请至少选择一个教学任务。");
if (taskIds.Count > 100)
return ValidationProblem("单次最多为 100 个教学任务创建实验项目。");
var tasks = await AccessibleTeachingTasks().AsNoTracking()
.Include(x => x.AcademicTerm)
.Where(x =>
taskIds.Contains(x.Id) &&
x.Status == TeachingTaskStatus.Published)
.OrderBy(x => x.TaskNumber)
.ToListAsync(cancellationToken);
if (tasks.Count != taskIds.Count)
return ValidationProblem("部分教学任务不存在、未发布或不在当前管理范围内。");
var first = tasks[0];
if (tasks.Any(x =>
x.AcademicTermId != first.AcademicTermId ||
x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
var code = request.Code.Trim();
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.Where(x =>
taskIds.Contains(x.TeachingTaskId) &&
x.Code == code)
.Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x)
.ToListAsync(cancellationToken);
if (conflictingTaskNumbers.Count > 0)
return ConflictProblem(
$"以下教学任务已存在实验项目编码 {code}{string.Join("", conflictingTaskNumbers)}。");
var projects = new List<ExperimentProject>(tasks.Count);
foreach (var task in tasks)
{
var item = request.ForTeachingTask(task.Id);
var problem = ValidateProjectRequest(item, task.AcademicTerm!);
if (problem is not null) return ValidationProblem(problem);
projects.Add(new ExperimentProject
{
TeachingTaskId = task.Id,
Code = code,
Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description),
Requirements = Normalize(request.Requirements),
StartDate = request.StartDate,
EndDate = request.EndDate
});
}
db.ExperimentProjects.AddRange(projects);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new
{
Count = projects.Count,
ProjectIds = projects.Select(x => x.Id)
});
}
[HttpPut("{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateProject(
@@ -478,6 +551,101 @@ public sealed class ExperimentsController(
return Created(string.Empty, new { session.Id });
}
[HttpPost("sessions/batch")]
[Authorize(Roles = Managers)]
public Task<ActionResult> CreateSessions(
ExperimentSessionBatchRequest request,
CancellationToken cancellationToken)
{
if (request.Items.Count == 0)
return Task.FromResult<ActionResult>(
ValidationProblem("请至少添加一条实验排课。"));
if (request.Items.Count > 100)
return Task.FromResult<ActionResult>(
ValidationProblem("单次最多安排 100 条实验场次。"));
if (request.Items.Any(x => x.ProjectId == Guid.Empty) ||
request.Items.Select(x => x.ProjectId).Distinct().Count() !=
request.Items.Count)
return Task.FromResult<ActionResult>(
ValidationProblem("同一批次中每个实验项目只能安排一个场次。"));
return db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
var projectIds = request.Items.Select(x => x.ProjectId).ToList();
var projects = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.Where(x => projectIds.Contains(x.Id))
.ToDictionaryAsync(x => x.Id, cancellationToken);
if (projects.Count != projectIds.Count)
return ValidationProblem(
"部分实验项目不存在或不在当前管理范围内。");
var createdSessions = new List<(ExperimentProject Project, ExperimentSession Session)>(
request.Items.Count);
foreach (var item in request.Items)
{
var project = projects[item.ProjectId];
if (project.Status == ExperimentProjectStatus.Closed)
return ConflictProblem(
$"实验项目“{project.Name}”已关闭,不能再增加场次。");
var sessionRequest = item.ToSessionRequest();
var problem = await ValidateSessionAsync(
project,
sessionRequest,
cancellationToken);
if (problem is not null)
return ConflictProblem(
$"实验项目“{project.Name}”:{problem}");
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = item.ClassroomId,
SessionDate = item.SessionDate,
StartPeriod = item.StartPeriod,
PeriodCount = item.PeriodCount,
Capacity = await ResolveSessionCapacityAsync(
project,
item.Capacity,
cancellationToken),
Notes = Normalize(item.Notes)
};
db.ExperimentSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
createdSessions.Add((project, session));
}
foreach (var (project, session) in createdSessions.Where(x =>
x.Project.Status == ExperimentProjectStatus.Published))
{
var userIds = await RosterUserIdsAsync(
project.TeachingTaskId,
cancellationToken);
if (userIds.Count == 0) continue;
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"新增实验场次",
$"“{project.Name}”新增 {session.SessionDate:yyyy-MM-dd} 第 {session.StartPeriod}—{session.StartPeriod + session.PeriodCount - 1} 节场次,请查看实验安排。",
"/experiments",
cancellationToken,
NotificationCategory.Schedule);
}
await transaction.CommitAsync(cancellationToken);
return Created(string.Empty, new
{
Count = createdSessions.Count,
SessionIds = createdSessions.Select(x => x.Session.Id)
});
},
cancellationToken,
IsolationLevel.Serializable);
}
[HttpDelete("sessions/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CancelSession(
@@ -1029,6 +1197,28 @@ public sealed record ExperimentProjectRequest(
DateOnly StartDate,
DateOnly EndDate);
public sealed record ExperimentProjectBatchRequest(
[Required] IReadOnlyList<Guid> TeachingTaskIds,
[Required, MaxLength(40)] string Code,
[Required, MaxLength(120)] string Name,
ExperimentArrangementMode ArrangementMode,
[MaxLength(1000)] string? Description,
[MaxLength(1000)] string? Requirements,
DateOnly StartDate,
DateOnly EndDate)
{
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new(
teachingTaskId,
Code,
Name,
ArrangementMode,
Description,
Requirements,
StartDate,
EndDate);
}
public sealed record ExperimentSessionRequest(
Guid ClassroomId,
DateOnly SessionDate,
@@ -1037,6 +1227,28 @@ public sealed record ExperimentSessionRequest(
[Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes);
public sealed record ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
public sealed record ExperimentSessionBatchItem(
Guid ProjectId,
Guid ClassroomId,
DateOnly SessionDate,
[Range(1, 30)] int StartPeriod,
[Range(1, 30)] int PeriodCount,
[Range(1, 10000)] int Capacity,
[MaxLength(500)] string? Notes)
{
public ExperimentSessionRequest ToSessionRequest() =>
new(
ClassroomId,
SessionDate,
StartPeriod,
PeriodCount,
Capacity,
Notes);
}
public sealed record ExperimentPeriodOption(
Guid AcademicTermId,
int PeriodNumber,
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -18,8 +19,27 @@ public sealed class OperationsController(
AppDbContext db,
OperationalHealthService healthService,
DatabaseBackupService backupService,
PerformanceReportService performanceReportService,
OperationsOptions options) : ControllerBase
{
[HttpGet("performance")]
public async Task<ActionResult<PerformanceReport>> GetPerformance(
[FromQuery] string? range = "1h",
CancellationToken cancellationToken = default)
{
try
{
return Ok(await performanceReportService.GetAsync(
range,
cancellationToken));
}
catch (ArgumentOutOfRangeException)
{
return ValidationProblem(
"性能报表范围仅支持 15m、1h、24h 或 7d。");
}
}
[HttpGet("summary")]
public async Task<ActionResult<OperationsSummary>> GetSummary(
CancellationToken cancellationToken)
@@ -3,7 +3,6 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -110,15 +109,6 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
return Ok(tasks.Select(task =>
{
constraints.TryGetValue(task.Id, out var constraint);
var weeklyHours = task.WeeklyHours;
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out var regularWeeklyHours))
weeklyHours = regularWeeklyHours;
return new
{
task.Id,
@@ -132,7 +122,7 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
task.Capacity,
task.StartWeek,
task.EndWeek,
WeeklyHours = weeklyHours,
task.WeeklyHours,
task.CourseTotalHours,
task.CoursePracticeHours,
task.SchedulingMode,
@@ -71,6 +71,7 @@ public sealed class SchedulesController(
{
entry.Id,
entry.TeachingTaskId,
entry.Kind,
TaskNumber = entry.TeachingTask!.TaskNumber,
TaskName = entry.TeachingTask.Name,
CourseCode = entry.TeachingTask.Course!.Code,
@@ -167,6 +168,7 @@ public sealed class SchedulesController(
Entries = source.Entries.Select(entry => new ScheduleEntry
{
TeachingTaskId = entry.TeachingTaskId,
Kind = entry.Kind,
ClassroomId = entry.ClassroomId,
DayOfWeek = entry.DayOfWeek,
StartPeriod = entry.StartPeriod,
@@ -419,6 +421,7 @@ public sealed class SchedulesController(
var validation = await ValidateEntryAsync(plan, entryId, request, cancellationToken);
if (validation is not null) return validation;
entry.TeachingTaskId = request.TeachingTaskId;
entry.Kind = request.Kind;
entry.ClassroomId = request.ClassroomId;
entry.DayOfWeek = request.DayOfWeek;
entry.StartPeriod = request.StartPeriod;
@@ -508,34 +511,52 @@ public sealed class SchedulesController(
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.Course!,
task.StartWeek,
task.EndWeek,
out var requiredWeeklyHours))
var targetHours = TeachingTaskHours.TargetHours(
task.Course!,
request.Kind);
if (targetHours == 0)
return ValidationProblem(
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
if (requiredWeeklyHours == 0)
return ValidationProblem(
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
var existingHours = await db.ScheduleEntries.AsNoTracking()
request.Kind == ScheduleEntryKind.Experiment
? "该课程没有实践学时,不能安排实验课。"
: "该课程没有理论学时,不能安排理论课。");
var existingEntries = await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlanId == plan.Id &&
x.TeachingTaskId == request.TeachingTaskId &&
x.Kind == request.Kind &&
x.Id != entryId)
.SumAsync(x => x.PeriodCount, cancellationToken);
if (existingHours + request.PeriodCount > requiredWeeklyHours)
.Select(x => new
{
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.PeriodCount
})
.ToListAsync(cancellationToken);
var existingHours = existingEntries.Sum(x =>
TeachingTaskHours.ScheduledHours(
x.StartWeek,
x.EndWeek,
x.WeekPattern,
x.PeriodCount));
var proposedHours = TeachingTaskHours.ScheduledHours(
request.StartWeek,
request.EndWeek,
request.WeekPattern,
request.PeriodCount);
if (existingHours + proposedHours > targetHours)
return ValidationProblem(
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
"实践学时请在实验管理中安排。");
$"该教学任务{(request.Kind == ScheduleEntryKind.Experiment ? "" : "")}课" +
$"共需 {targetHours} 学时;当前操作后将达到 " +
$"{existingHours + proposedHours} 学时。");
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
.Include(x => x.AllowedClassrooms)
.FirstOrDefaultAsync(
x => x.TeachingTaskId == request.TeachingTaskId,
cancellationToken);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
var requiresClassroom = request.Kind == ScheduleEntryKind.Experiment ||
constraint?.RequiresClassroom != false;
if (requiresClassroom && !request.ClassroomId.HasValue)
return ValidationProblem("该课程需要占用教室,请选择教室。");
if (!requiresClassroom && request.ClassroomId.HasValue)
@@ -559,6 +580,10 @@ public sealed class SchedulesController(
x => x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken);
if (classroom is null) return ValidationProblem("所选教室不存在或已停用。");
if (request.Kind == ScheduleEntryKind.Experiment &&
!IsExperimentRoom(classroom.RoomType))
return ValidationProblem(
$"实验课必须安排在实验室、实训室或机房;“{classroom.Name}”的场地类型为“{classroom.RoomType}”。");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
return ValidationProblem("所选教室不在该课程指定的校区。");
@@ -612,6 +637,7 @@ public sealed class SchedulesController(
{
SchedulePlanId = planId,
TeachingTaskId = request.TeachingTaskId,
Kind = request.Kind,
ClassroomId = request.ClassroomId,
DayOfWeek = request.DayOfWeek,
StartPeriod = request.StartPeriod,
@@ -629,6 +655,12 @@ public sealed class SchedulesController(
.Select(int.Parse)
.ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
@@ -726,7 +758,8 @@ public sealed record ScheduleEntryRequest(
[Range(1, 30)] int StartWeek,
[Range(1, 30)] int EndWeek,
WeekPattern WeekPattern,
[MaxLength(500)] string? Notes);
[MaxLength(500)] string? Notes,
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture);
public sealed record AutomaticScheduleJobResponse(
Guid Id,
@@ -0,0 +1,28 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Route("api/system")]
public sealed class SystemController : ControllerBase
{
[AllowAnonymous]
[HttpGet("version")]
[ProducesResponseType<SystemVersionResponse>(StatusCodes.Status200OK)]
public SystemVersionResponse GetVersion()
{
var assembly = typeof(SystemController).Assembly;
var informationalVersion = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
var version = informationalVersion?.Split('+', 2)[0]
?? assembly.GetName().Version?.ToString(3)
?? "unknown";
return new SystemVersionResponse(version);
}
}
public sealed record SystemVersionResponse(string Version);
@@ -20,6 +20,7 @@ public sealed class ScheduleEntry : EntityBase
public SchedulePlan? SchedulePlan { get; set; }
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public ScheduleEntryKind Kind { get; set; } = ScheduleEntryKind.Lecture;
public Guid? ClassroomId { get; set; }
public Classroom? Classroom { get; set; }
public int DayOfWeek { get; set; }
@@ -129,3 +130,9 @@ public enum WeekPattern
Odd = 2,
Even = 3
}
public enum ScheduleEntryKind
{
Lecture = 1,
Experiment = 2
}
@@ -0,0 +1,281 @@
using System.Data.Common;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class DatabaseCommandTelemetryInterceptor(
ObservabilityOptions options,
ILogger<DatabaseCommandTelemetryInterceptor> logger)
: DbCommandInterceptor
{
public const string ActivitySourceName = "Jiaowu.Api.Database";
public const string MeterName = "Jiaowu.Api.Database";
private static readonly ActivitySource ActivitySource =
new(ActivitySourceName);
private static readonly Meter Meter = new(MeterName);
private static readonly Histogram<double> CommandDuration =
Meter.CreateHistogram<double>(
"jiaowu.db.command.duration",
"ms",
"EF Core database command duration");
private static readonly Counter<long> SlowCommandCount =
Meter.CreateCounter<long>(
"jiaowu.db.command.slow",
"{command}",
"EF Core commands exceeding the configured slow-query threshold");
private static readonly Counter<long> FailedCommandCount =
Meter.CreateCounter<long>(
"jiaowu.db.command.failed",
"{command}",
"Failed EF Core database commands");
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
Observe(command, eventData.Duration, "reader");
return result;
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "reader");
return ValueTask.FromResult(result);
}
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
Observe(command, eventData.Duration, "nonquery");
return result;
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "nonquery");
return ValueTask.FromResult(result);
}
public override object? ScalarExecuted(
DbCommand command,
CommandExecutedEventData eventData,
object? result)
{
Observe(command, eventData.Duration, "scalar");
return result;
}
public override ValueTask<object?> ScalarExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
object? result,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "scalar");
return ValueTask.FromResult(result);
}
public override void CommandFailed(
DbCommand command,
CommandErrorEventData eventData) =>
Observe(
command,
eventData.Duration,
"failed",
eventData.Exception.GetType().Name);
public override Task CommandFailedAsync(
DbCommand command,
CommandErrorEventData eventData,
CancellationToken cancellationToken = default)
{
Observe(
command,
eventData.Duration,
"failed",
eventData.Exception.GetType().Name);
return Task.CompletedTask;
}
public override void CommandCanceled(
DbCommand command,
CommandEndEventData eventData) =>
Observe(command, eventData.Duration, "canceled", "canceled");
public override Task CommandCanceledAsync(
DbCommand command,
CommandEndEventData eventData,
CancellationToken cancellationToken = default)
{
Observe(command, eventData.Duration, "canceled", "canceled");
return Task.CompletedTask;
}
private void Observe(
DbCommand command,
TimeSpan duration,
string commandKind,
string? errorType = null)
{
if (!options.Enabled) return;
var queryName = GetQueryName(command.CommandText);
var statementHash = GetStatementHash(command.CommandText);
var provider = GetProviderName(command);
var traceId = Activity.Current?.TraceId.ToString() ?? "none";
var tags = new TagList
{
{ "db.system.name", provider },
{ "db.operation.name", commandKind },
{ "db.query.name", queryName }
};
if (errorType is not null)
tags.Add("error.type", errorType);
var durationMilliseconds = duration.TotalMilliseconds;
CommandDuration.Record(durationMilliseconds, tags);
if (errorType is not null)
FailedCommandCount.Add(1, tags);
using var activity = ActivitySource.StartActivity(
ActivityKind.Client,
Activity.Current?.Context ?? default,
startTime: DateTimeOffset.UtcNow - duration,
name: queryName);
if (activity is not null)
{
activity.SetTag("db.system.name", provider);
activity.SetTag("db.operation.name", commandKind);
activity.SetTag("db.query.name", queryName);
activity.SetTag("db.statement.hash", statementHash);
activity.SetTag(
"db.namespace",
EmptyToNull(command.Connection?.Database));
if (options.IncludeSqlText)
{
activity.SetTag(
"db.query.text",
Truncate(command.CommandText, options.MaximumSqlTextLength));
}
if (errorType is not null)
{
activity.SetTag("error.type", errorType);
activity.SetStatus(ActivityStatusCode.Error, errorType);
}
activity.SetEndTime(DateTime.UtcNow);
}
if (errorType is not null)
{
logger.LogError(
"Database command failed after {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"error {ErrorType}, trace {TraceId}).",
durationMilliseconds,
queryName,
commandKind,
provider,
statementHash,
errorType,
traceId);
return;
}
if (durationMilliseconds < options.SlowQueryThresholdMilliseconds)
return;
SlowCommandCount.Add(1, tags);
if (options.IncludeSqlText)
{
logger.LogWarning(
"Slow database command took {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"trace {TraceId}). " +
"SQL template: {SqlTemplate}",
durationMilliseconds,
queryName,
commandKind,
provider,
statementHash,
traceId,
Truncate(command.CommandText, options.MaximumSqlTextLength));
}
else
{
logger.LogWarning(
"Slow database command took {DurationMs:F1} ms: " +
"{QueryName} ({CommandKind}, {Provider}, hash {StatementHash}, " +
"trace {TraceId}).",
durationMilliseconds,
queryName,
commandKind,
provider,
statementHash,
traceId);
}
}
internal static string GetQueryName(string commandText)
{
using var reader = new StringReader(commandText);
while (reader.ReadLine() is { } line)
{
var trimmed = line.Trim();
if (trimmed.Length == 0) continue;
if (trimmed.StartsWith("-- ", StringComparison.Ordinal))
return Truncate(trimmed[3..].Trim(), 120);
return $"{FirstToken(trimmed)}:{GetStatementHash(commandText)}";
}
return $"unknown:{GetStatementHash(commandText)}";
}
internal static string GetStatementHash(string commandText)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandText));
return Convert.ToHexString(bytes.AsSpan(0, 6)).ToLowerInvariant();
}
private static string FirstToken(string value)
{
var end = value.IndexOfAny([' ', '\t', '\r', '\n', '(']);
var token = end < 0 ? value : value[..end];
return token.Length == 0
? "command"
: token.ToLowerInvariant();
}
private static string GetProviderName(DbCommand command)
{
var typeName = command.GetType().FullName ?? command.GetType().Name;
if (typeName.Contains("MySql", StringComparison.OrdinalIgnoreCase))
return "mysql";
if (typeName.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
return "sqlite";
return "other_sql";
}
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
private static string Truncate(string value, int maximumLength) =>
value.Length <= maximumLength
? value
: value[..maximumLength];
}
@@ -0,0 +1,12 @@
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class ObservabilityOptions
{
public const string SectionName = "Observability";
public bool Enabled { get; set; } = true;
public string ServiceName { get; set; } = "jiaowu-api";
public int SlowQueryThresholdMilliseconds { get; set; } = 500;
public bool IncludeSqlText { get; set; }
public int MaximumSqlTextLength { get; set; } = 2000;
}
@@ -0,0 +1,556 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed class PerformanceReportService(
HttpClient httpClient,
IMemoryCache cache,
PerformanceReportingOptions options,
ObservabilityOptions observability,
ILogger<PerformanceReportService> logger)
{
public async Task<PerformanceReport> GetAsync(
string? range,
CancellationToken cancellationToken)
{
var rangeSpec = PerformanceRange.TryParse(range);
if (rangeSpec is null)
throw new ArgumentOutOfRangeException(
nameof(range),
"性能报表范围仅支持 15m、1h、24h 或 7d。");
if (!options.Enabled ||
string.IsNullOrWhiteSpace(options.PrometheusBaseUrl))
{
return PerformanceReport.NotConfigured(
rangeSpec.Key,
options.GrafanaBaseUrl);
}
var cacheKey = $"performance-report:{rangeSpec.Key}";
if (cache.TryGetValue<PerformanceReport>(cacheKey, out var cached))
return cached!;
PerformanceReport report;
try
{
report = await LoadAsync(rangeSpec, cancellationToken);
}
catch (Exception exception) when (
!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(
exception,
"Performance report source is unavailable for range {Range}.",
rangeSpec.Key);
report = PerformanceReport.Unavailable(
rangeSpec.Key,
options.GrafanaBaseUrl);
}
cache.Set(
cacheKey,
report,
TimeSpan.FromSeconds(options.CacheSeconds));
return report;
}
private async Task<PerformanceReport> LoadAsync(
PerformanceRange range,
CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
var from = now - range.Duration;
var requestCountSelector = Selector(
options.RequestDurationMetric + "_count",
(options.ServiceLabel, "=", observability.ServiceName));
var requestBucketSelector = Selector(
options.RequestDurationMetric + "_bucket",
(options.ServiceLabel, "=", observability.ServiceName));
var errorCountSelector = Selector(
options.RequestDurationMetric + "_count",
(options.ServiceLabel, "=", observability.ServiceName),
("http_response_status_code", "=~", "5.."));
var databaseCountSelector = Selector(
options.DatabaseDurationMetric + "_count",
(options.ServiceLabel, "=", observability.ServiceName));
var databaseBucketSelector = Selector(
options.DatabaseDurationMetric + "_bucket",
(options.ServiceLabel, "=", observability.ServiceName));
var slowDatabaseSelector = Selector(
options.SlowDatabaseMetric,
(options.ServiceLabel, "=", observability.ServiceName));
var failedDatabaseSelector = Selector(
options.FailedDatabaseMetric,
(options.ServiceLabel, "=", observability.ServiceName));
var requestCountTask = QueryScalarAsync(
$"sum(increase({requestCountSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var serverErrorCountTask = QueryScalarAsync(
$"sum(increase({errorCountSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var requestP95Task = QueryScalarAsync(
"histogram_quantile(0.95, " +
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
"* 1000",
now,
cancellationToken);
var databaseP95Task = QueryScalarAsync(
"histogram_quantile(0.95, " +
$"sum by (le) (rate({databaseBucketSelector}[{range.RateWindow}])))",
now,
cancellationToken);
var slowCountTask = QueryScalarAsync(
$"sum(increase({slowDatabaseSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var failedCountTask = QueryScalarAsync(
$"sum(increase({failedDatabaseSelector}[{range.PrometheusRange}]))",
now,
cancellationToken);
var requestTimelineTask = QueryRangeAsync(
$"sum(rate({requestCountSelector}[{range.RateWindow}]))",
from,
now,
range.StepSeconds,
cancellationToken);
var latencyTimelineTask = QueryRangeAsync(
"histogram_quantile(0.95, " +
$"sum by (le) (rate({requestBucketSelector}[{range.RateWindow}]))) " +
"* 1000",
from,
now,
range.StepSeconds,
cancellationToken);
var routeLatencyTask = QueryVectorAsync(
"histogram_quantile(0.95, " +
$"sum by (le, http_route) (rate({requestBucketSelector}" +
$"[{range.RateWindow}]))) * 1000",
now,
cancellationToken);
var routeCountTask = QueryVectorAsync(
$"sum by (http_route) (increase({requestCountSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
var routeErrorTask = QueryVectorAsync(
$"sum by (http_route) (increase({errorCountSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
var queryLatencyTask = QueryVectorAsync(
"histogram_quantile(0.95, " +
$"sum by (le, db_query_name) (rate({databaseBucketSelector}" +
$"[{range.RateWindow}])))",
now,
cancellationToken);
var queryCountTask = QueryVectorAsync(
$"sum by (db_query_name) (increase({databaseCountSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
var querySlowTask = QueryVectorAsync(
$"sum by (db_query_name) (increase({slowDatabaseSelector}" +
$"[{range.PrometheusRange}]))",
now,
cancellationToken);
await Task.WhenAll(
requestCountTask,
serverErrorCountTask,
requestP95Task,
databaseP95Task,
slowCountTask,
failedCountTask,
requestTimelineTask,
latencyTimelineTask,
routeLatencyTask,
routeCountTask,
routeErrorTask,
queryLatencyTask,
queryCountTask,
querySlowTask);
var requestCount = await requestCountTask;
var serverErrorCount = await serverErrorCountTask;
double? errorRate = requestCount is > 0 && serverErrorCount.HasValue
? serverErrorCount.Value / requestCount.Value * 100
: requestCount == 0
? 0
: null;
var timeline = MergeTimeline(
await requestTimelineTask,
await latencyTimelineTask);
var endpoints = MergeRanking(
await routeLatencyTask,
await routeCountTask,
await routeErrorTask,
"http_route");
var databaseQueries = MergeRanking(
await queryLatencyTask,
await queryCountTask,
await querySlowTask,
"db_query_name");
return new PerformanceReport(
"ready",
range.Key,
from,
now,
DateTime.UtcNow,
"prometheus",
EmptyToNull(options.GrafanaBaseUrl),
null,
new PerformanceHeadline(
Round(requestCount),
Round(await requestP95Task),
Round(errorRate),
Round(await databaseP95Task),
Round(await slowCountTask),
Round(await failedCountTask)),
timeline,
endpoints,
databaseQueries);
}
private async Task<double?> QueryScalarAsync(
string query,
DateTime time,
CancellationToken cancellationToken)
{
var vector = await QueryVectorAsync(
query,
time,
cancellationToken);
return vector.FirstOrDefault()?.Value;
}
private async Task<IReadOnlyList<PrometheusSample>> QueryVectorAsync(
string query,
DateTime time,
CancellationToken cancellationToken)
{
var uri = BuildUri(
"api/v1/query",
("query", query),
("time", ToUnixSeconds(time).ToString(
CultureInfo.InvariantCulture)));
using var document = await SendAsync(uri, cancellationToken);
var data = document.RootElement.GetProperty("data");
var result = data.GetProperty("result");
var samples = new List<PrometheusSample>();
foreach (var item in result.EnumerateArray())
{
var labels = ReadLabels(item.GetProperty("metric"));
if (!TryReadValue(item.GetProperty("value"), out var value))
continue;
samples.Add(new PrometheusSample(labels, value));
}
return samples;
}
private async Task<IReadOnlyList<PerformanceSeriesPoint>> QueryRangeAsync(
string query,
DateTime from,
DateTime to,
int stepSeconds,
CancellationToken cancellationToken)
{
var uri = BuildUri(
"api/v1/query_range",
("query", query),
("start", ToUnixSeconds(from).ToString(
CultureInfo.InvariantCulture)),
("end", ToUnixSeconds(to).ToString(
CultureInfo.InvariantCulture)),
("step", stepSeconds.ToString(CultureInfo.InvariantCulture)));
using var document = await SendAsync(uri, cancellationToken);
var result = document.RootElement
.GetProperty("data")
.GetProperty("result");
var first = result.EnumerateArray().FirstOrDefault();
if (first.ValueKind == JsonValueKind.Undefined ||
!first.TryGetProperty("values", out var values))
return [];
var points = new List<PerformanceSeriesPoint>();
foreach (var value in values.EnumerateArray())
{
if (!TryReadValue(value, out var measurement)) continue;
var timestamp = value[0].GetDouble();
points.Add(new PerformanceSeriesPoint(
DateTimeOffset.FromUnixTimeMilliseconds(
checked((long)(timestamp * 1000))).UtcDateTime,
measurement));
}
return points;
}
private async Task<JsonDocument> SendAsync(
Uri uri,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
if (!string.IsNullOrWhiteSpace(options.BearerToken))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", options.BearerToken);
}
using var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(
cancellationToken);
var document = await JsonDocument.ParseAsync(
stream,
cancellationToken: cancellationToken);
if (!document.RootElement.TryGetProperty("status", out var status) ||
status.GetString() != "success")
{
document.Dispose();
throw new InvalidOperationException(
"Prometheus 返回了非成功查询状态。");
}
return document;
}
private Uri BuildUri(
string relativePath,
params (string Key, string Value)[] parameters)
{
var baseUri = new Uri(
options.PrometheusBaseUrl.TrimEnd('/') + "/",
UriKind.Absolute);
var query = string.Join(
"&",
parameters.Select(parameter =>
$"{Uri.EscapeDataString(parameter.Key)}=" +
$"{Uri.EscapeDataString(parameter.Value)}"));
return new Uri(baseUri, $"{relativePath}?{query}");
}
private static string Selector(
string metric,
params (string Label, string Operator, string Value)[] filters)
{
var matchers = string.Join(
",",
filters.Select(filter =>
$"{filter.Label}{filter.Operator}\"" +
$"{EscapePrometheusValue(filter.Value)}\""));
return $"{metric}{{{matchers}}}";
}
private static string EscapePrometheusValue(string value) =>
value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
private static IReadOnlyDictionary<string, string> ReadLabels(
JsonElement metric)
{
var result = new Dictionary<string, string>(
StringComparer.Ordinal);
foreach (var property in metric.EnumerateObject())
result[property.Name] = property.Value.GetString() ?? "";
return result;
}
private static bool TryReadValue(
JsonElement value,
out double measurement)
{
measurement = 0;
if (value.ValueKind != JsonValueKind.Array ||
value.GetArrayLength() < 2)
return false;
var raw = value[1].GetString();
return double.TryParse(
raw,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out measurement) &&
double.IsFinite(measurement);
}
private static IReadOnlyList<PerformanceTimelinePoint> MergeTimeline(
IReadOnlyList<PerformanceSeriesPoint> requestRate,
IReadOnlyList<PerformanceSeriesPoint> latency)
{
var points = new SortedDictionary<DateTime, PerformanceTimelinePoint>();
foreach (var point in requestRate)
{
points[point.Timestamp] = new PerformanceTimelinePoint(
point.Timestamp,
Math.Round(point.Value, 3),
null);
}
foreach (var point in latency)
{
points.TryGetValue(point.Timestamp, out var existing);
points[point.Timestamp] = new PerformanceTimelinePoint(
point.Timestamp,
existing?.RequestsPerSecond,
Math.Round(point.Value, 2));
}
return points.Values.ToArray();
}
private static IReadOnlyList<PerformanceRankingItem> MergeRanking(
IReadOnlyList<PrometheusSample> latency,
IReadOnlyList<PrometheusSample> count,
IReadOnlyList<PrometheusSample> exceptional,
string label)
{
var names = latency
.Concat(count)
.Concat(exceptional)
.Select(item => item.Labels.GetValueOrDefault(label))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.Ordinal)
.ToArray();
var items = names.Select(name =>
{
var latencyValue = FindValue(latency, label, name);
var countValue = FindValue(count, label, name);
var exceptionalValue = FindValue(exceptional, label, name);
return new PerformanceRankingItem(
name!,
Round(latencyValue),
Round(countValue),
Round(exceptionalValue));
});
return items
.OrderByDescending(item => item.P95Milliseconds ?? -1)
.ThenByDescending(item => item.RequestCount ?? -1)
.Take(10)
.ToArray();
}
private static double? FindValue(
IReadOnlyList<PrometheusSample> samples,
string label,
string? name) =>
samples.FirstOrDefault(item =>
item.Labels.GetValueOrDefault(label) == name)?.Value;
private static double ToUnixSeconds(DateTime value) =>
new DateTimeOffset(
DateTime.SpecifyKind(value, DateTimeKind.Utc)).ToUnixTimeMilliseconds()
/ 1000d;
private static double? Round(double? value) =>
value.HasValue && double.IsFinite(value.Value)
? Math.Round(value.Value, 2)
: null;
private static string? EmptyToNull(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
private sealed record PrometheusSample(
IReadOnlyDictionary<string, string> Labels,
double Value);
private sealed record PerformanceSeriesPoint(
DateTime Timestamp,
double Value);
}
public sealed record PerformanceHeadline(
double? RequestCount,
double? RequestP95Milliseconds,
double? ServerErrorRatePercent,
double? DatabaseP95Milliseconds,
double? SlowDatabaseCommandCount,
double? FailedDatabaseCommandCount);
public sealed record PerformanceTimelinePoint(
DateTime Timestamp,
double? RequestsPerSecond,
double? RequestP95Milliseconds);
public sealed record PerformanceRankingItem(
string Name,
double? P95Milliseconds,
double? RequestCount,
double? ExceptionalCount);
public sealed record PerformanceReport(
string Status,
string Range,
DateTime? From,
DateTime? To,
DateTime GeneratedAt,
string DataSource,
string? DashboardUrl,
string? Detail,
PerformanceHeadline? Headline,
IReadOnlyList<PerformanceTimelinePoint> Timeline,
IReadOnlyList<PerformanceRankingItem> Endpoints,
IReadOnlyList<PerformanceRankingItem> DatabaseQueries)
{
public static PerformanceReport NotConfigured(
string range,
string? dashboardUrl) =>
Empty(
"not_configured",
range,
dashboardUrl,
"尚未配置 Prometheus 数据源。请先部署指标存储并设置 " +
"PerformanceReporting__PrometheusBaseUrl。");
public static PerformanceReport Unavailable(
string range,
string? dashboardUrl) =>
Empty(
"unavailable",
range,
dashboardUrl,
"性能数据源暂时不可用。系统业务不受影响,请检查 Prometheus 与网络配置。");
private static PerformanceReport Empty(
string status,
string range,
string? dashboardUrl,
string detail) =>
new(
status,
range,
null,
null,
DateTime.UtcNow,
"prometheus",
string.IsNullOrWhiteSpace(dashboardUrl) ? null : dashboardUrl,
detail,
null,
[],
[],
[]);
}
internal sealed record PerformanceRange(
string Key,
TimeSpan Duration,
string PrometheusRange,
string RateWindow,
int StepSeconds)
{
public static PerformanceRange? TryParse(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"15m" => new("15m", TimeSpan.FromMinutes(15), "15m", "1m", 30),
"1h" => new("1h", TimeSpan.FromHours(1), "1h", "5m", 60),
"24h" => new("24h", TimeSpan.FromHours(24), "24h", "15m", 900),
"7d" => new("7d", TimeSpan.FromDays(7), "7d", "1h", 3600),
_ => null
};
}
@@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
namespace Jiaowu.Api.Infrastructure.Observability;
public sealed partial class PerformanceReportingOptions
{
public const string SectionName = "PerformanceReporting";
public bool Enabled { get; set; }
public string PrometheusBaseUrl { get; set; } = "";
public string BearerToken { get; set; } = "";
public string GrafanaBaseUrl { get; set; } = "";
public int CacheSeconds { get; set; } = 30;
public int TimeoutSeconds { get; set; } = 10;
public string ServiceLabel { get; set; } = "service_name";
public string RequestDurationMetric { get; set; } =
"http_server_request_duration_seconds";
public string DatabaseDurationMetric { get; set; } =
"jiaowu_db_command_duration_milliseconds";
public string SlowDatabaseMetric { get; set; } =
"jiaowu_db_command_slow_total";
public string FailedDatabaseMetric { get; set; } =
"jiaowu_db_command_failed_total";
public static bool IsMetricOrLabelName(string value) =>
!string.IsNullOrWhiteSpace(value) &&
PrometheusNamePattern().IsMatch(value);
[GeneratedRegex("^[a-zA-Z_:][a-zA-Z0-9_:]*$")]
private static partial Regex PrometheusNamePattern();
}
@@ -416,6 +416,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<ScheduleEntry>(entity =>
{
entity.Property(x => x.Kind)
.HasDefaultValue(ScheduleEntryKind.Lecture);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new
{
@@ -76,6 +76,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260728_40_experiment_grade_management";
private const string AppUpdateReleasesMigration =
"20260729_41_app_update_releases";
private const string IntegratedExperimentSchedulingMigration =
"20260802_42_integrated_experiment_scheduling";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -577,6 +579,20 @@ public sealed class DevelopmentSqliteMigrator(
AppUpdateReleasesMigration,
AppUpdateReleasesStatements,
cancellationToken);
var scheduleEntryKindExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('ScheduleEntries')
WHERE name = 'Kind'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
IntegratedExperimentSchedulingMigration,
scheduleEntryKindExists
? []
: IntegratedExperimentSchedulingStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2714,4 +2730,12 @@ public sealed class DevelopmentSqliteMigrator(
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
"""
];
private static readonly string[] IntegratedExperimentSchedulingStatements =
[
"""
ALTER TABLE "ScheduleEntries"
ADD COLUMN "Kind" INTEGER NOT NULL DEFAULT 1;
"""
];
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class IntegratedExperimentScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Kind",
table: "ScheduleEntries",
type: "int",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "ScheduleEntries");
}
}
}
@@ -3296,6 +3296,10 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("EndWeek")
.HasColumnType("int");
b.Property<int>("Kind")
.HasColumnType("int")
.HasDefaultValue(1);
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
@@ -74,97 +74,62 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
{
cancellationToken.ThrowIfCancellationRequested();
constraints.TryGetValue(task.Id, out var constraint);
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.Course!,
task.StartWeek,
task.EndWeek,
out var requiredWeeklyHours))
var taskCompleted = true;
foreach (var kind in new[]
{
ScheduleEntryKind.Lecture,
ScheduleEntryKind.Experiment
})
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
processedTasks++;
if (reportProgress is not null)
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
var scheduledHours = entries
.Where(x =>
x.TeachingTaskId == task.Id &&
x.Kind == kind)
.Sum(TeachingTaskHours.ScheduledHours);
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
if (scheduledHours > targetHours)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
messages.Add(
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
taskCompleted = false;
continue;
}
continue;
}
var scheduledHours = entries
.Where(x => x.TeachingTaskId == task.Id)
.Sum(x => x.PeriodCount);
if (scheduledHours > requiredWeeklyHours)
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
processedTasks++;
if (reportProgress is not null)
var remainingHours = targetHours - scheduledHours;
while (remainingHours > 0)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
continue;
}
var remainingHours = requiredWeeklyHours - scheduledHours;
if (remainingHours == 0)
{
completedTasks++;
processedTasks++;
if (reportProgress is not null)
{
await reportProgress(
new(tasks.Count, processedTasks, created, completedTasks),
cancellationToken);
}
continue;
}
while (remainingHours > 0)
{
var desiredBlock = remainingHours >= 2 ? 2 : 1;
var candidate = FindBestCandidate(
plan.Id,
task,
constraint,
desiredBlock,
activePeriods,
classrooms,
entries,
cancellationToken);
if (candidate is null && desiredBlock > 1)
{
candidate = FindBestCandidate(
var candidate = FindBestCandidateForHours(
plan.Id,
task,
constraint,
1,
kind,
remainingHours,
activePeriods,
classrooms,
entries,
cancellationToken);
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
}
if (candidate is null) break;
db.ScheduleEntries.Add(candidate);
entries.Add(candidate);
created++;
remainingHours -= candidate.PeriodCount;
if (remainingHours > 0)
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
(kind == ScheduleEntryKind.Experiment
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
: "请检查教师/班级冲突或场地与时间约束。"));
taskCompleted = false;
}
}
if (remainingHours == 0)
{
completedTasks++;
}
else
{
messages.Add(
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
}
if (taskCompleted) completedTasks++;
processedTasks++;
if (reportProgress is not null)
@@ -185,11 +150,51 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
processedTasks);
}
private static ScheduleEntry? FindBestCandidateForHours(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
ScheduleEntryKind kind,
int remainingHours,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries,
CancellationToken cancellationToken)
{
var weekCount = task.EndWeek - task.StartWeek + 1;
foreach (var periodCount in remainingHours >= 2
? new[] { 2, 1 }
: new[] { 1 })
{
var maxOccurrences = Math.Min(
weekCount,
remainingHours / periodCount);
for (var occurrences = maxOccurrences; occurrences >= 1; occurrences--)
{
var candidate = FindBestCandidate(
planId,
task,
constraint,
kind,
periodCount,
occurrences,
activePeriods,
classrooms,
entries,
cancellationToken);
if (candidate is not null) return candidate;
}
}
return null;
}
private static ScheduleEntry? FindBestCandidate(
Guid planId,
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
ScheduleEntryKind kind,
int periodCount,
int occurrenceCount,
HashSet<int> activePeriods,
IReadOnlyList<Classroom> classrooms,
IReadOnlyList<ScheduleEntry> entries,
@@ -198,50 +203,60 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
var allowedDays = ParseAllowedDays(constraint?.AllowedDayOfWeeks);
var firstPeriod = constraint?.EarliestPeriod ?? activePeriods.Min();
var lastPeriod = constraint?.LatestPeriod ?? activePeriods.Max();
var rooms = EligibleRooms(task, constraint, classrooms);
var rooms = EligibleRooms(task, constraint, kind, classrooms);
if ((constraint?.RequiresClassroom ?? true) && rooms.Count == 0)
return null;
var candidates = new List<(ScheduleEntry Entry, int Score)>();
foreach (var day in allowedDays)
for (var startWeek = task.StartWeek;
startWeek + occurrenceCount - 1 <= task.EndWeek;
startWeek++)
{
cancellationToken.ThrowIfCancellationRequested();
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
foreach (var day in allowedDays)
{
cancellationToken.ThrowIfCancellationRequested();
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
continue;
var roomOptions = constraint?.RequiresClassroom == false
? new Classroom?[] { null }
: rooms.Cast<Classroom?>().ToArray();
foreach (var room in roomOptions)
for (var start = firstPeriod; start + periodCount - 1 <= lastPeriod; start++)
{
var proposed = new ScheduleEntry
{
SchedulePlanId = planId,
TeachingTaskId = task.Id,
TeachingTask = task,
ClassroomId = room?.Id,
DayOfWeek = day,
StartPeriod = start,
PeriodCount = periodCount,
StartWeek = task.StartWeek,
EndWeek = task.EndWeek,
WeekPattern = WeekPattern.All,
Notes = "自动排课"
};
if (entries.Any(existing =>
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
cancellationToken.ThrowIfCancellationRequested();
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
continue;
var sameTaskDay = entries.Count(x =>
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
var dayLoad = entries.Count(x => x.DayOfWeek == day);
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
var score = sameTaskDay * 1000 + dayLoad * 10 + start + roomWaste / 10;
candidates.Add((proposed, score));
var roomOptions = kind != ScheduleEntryKind.Experiment &&
constraint?.RequiresClassroom == false
? new Classroom?[] { null }
: rooms.Cast<Classroom?>().ToArray();
foreach (var room in roomOptions)
{
var proposed = new ScheduleEntry
{
SchedulePlanId = planId,
TeachingTaskId = task.Id,
TeachingTask = task,
Kind = kind,
ClassroomId = room?.Id,
DayOfWeek = day,
StartPeriod = start,
PeriodCount = periodCount,
StartWeek = startWeek,
EndWeek = startWeek + occurrenceCount - 1,
WeekPattern = WeekPattern.All,
Notes = kind == ScheduleEntryKind.Experiment
? "自动排课 · 实验课"
: "自动排课 · 理论课"
};
if (entries.Any(existing =>
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
ScheduleConflictDetector.ConflictReason(existing, proposed) is not null))
continue;
var sameTaskDay = entries.Count(x =>
x.TeachingTaskId == task.Id && x.DayOfWeek == day);
var dayLoad = entries.Count(x => x.DayOfWeek == day);
var roomWaste = room is null ? 0 : Math.Max(0, room.Capacity - task.Capacity);
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
roomWaste / 10 + startWeek;
candidates.Add((proposed, score));
}
}
}
}
@@ -256,9 +271,11 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
private static IReadOnlyList<Classroom> EligibleRooms(
TeachingTask task,
TeachingTaskScheduleConstraint? constraint,
ScheduleEntryKind kind,
IReadOnlyList<Classroom> classrooms)
{
if (constraint?.RequiresClassroom == false) return [];
if (kind != ScheduleEntryKind.Experiment &&
constraint?.RequiresClassroom == false) return [];
var allowedRoomIds = constraint?.AllowedClassrooms
.Select(x => x.ClassroomId)
.ToHashSet() ?? [];
@@ -273,10 +290,17 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
room.Building!.CampusId == requiredCampusId) &&
(constraint?.RequiredBuildingId is not Guid requiredBuildingId ||
room.BuildingId == requiredBuildingId) &&
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)))
(allowedRoomIds.Count == 0 || allowedRoomIds.Contains(room.Id)) &&
(kind != ScheduleEntryKind.Experiment || IsExperimentRoom(room.RoomType)))
.ToList();
}
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
private static int[] ParseAllowedDays(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return [1, 2, 3, 4, 5];
@@ -191,57 +191,30 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
CoursePracticeHours = x.Course.PracticeHours
})
.ToListAsync(cancellationToken);
var invalidHours = requiredTasks.FirstOrDefault(task =>
!TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out _));
if (invalidHours is not null)
{
throw new SchedulePublishValidationException(
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
"不能按授课周次整除,请先调整教学任务周次。");
}
var requiredWeeklyHours = requiredTasks
.Select(task =>
{
TeachingTaskHours.TryResolveRegularWeeklyHours(
task.CourseTotalHours,
task.CoursePracticeHours,
task.StartWeek,
task.EndWeek,
out var hours);
return new
{
Task = task,
Hours = hours
};
})
.ToList();
var scheduledHours = plan.Entries
.GroupBy(x => x.TeachingTaskId)
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
if (incomplete is not null)
.GroupBy(x => new { x.TeachingTaskId, x.Kind })
.ToDictionary(
group => (group.Key.TeachingTaskId, group.Key.Kind),
group => group.Sum(TeachingTaskHours.ScheduledHours));
foreach (var task in requiredTasks)
{
throw new SchedulePublishValidationException(
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
$"{incomplete.Hours} 个普通排课学时,不能发布。");
}
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
if (excessive is not null)
{
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
throw new SchedulePublishValidationException(
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
"请删除已包含的实践学时后再发布。");
var targets = new[]
{
(Kind: ScheduleEntryKind.Lecture,
Hours: Math.Max(0, task.CourseTotalHours - task.CoursePracticeHours),
Label: "理论课"),
(Kind: ScheduleEntryKind.Experiment,
Hours: Math.Max(0, task.CoursePracticeHours),
Label: "实验课")
};
foreach (var target in targets)
{
var actual = scheduledHours.GetValueOrDefault((task.Id, target.Kind));
if (actual == target.Hours) continue;
throw new SchedulePublishValidationException(
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
}
}
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
@@ -275,7 +248,8 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
Fail(entry, "排课周次不在教学任务的授课周次内");
constraints.TryGetValue(entry.TeachingTaskId, out var constraint);
var requiresClassroom = constraint?.RequiresClassroom ?? true;
var requiresClassroom = entry.Kind == ScheduleEntryKind.Experiment ||
constraint?.RequiresClassroom != false;
if (requiresClassroom && entry.ClassroomId is null)
Fail(entry, "该课程需要占用教室");
if (!requiresClassroom && entry.ClassroomId is not null)
@@ -296,6 +270,9 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
{
if (classroom is null || !classroom.IsEnabled)
Fail(entry, "所选教室不存在或已停用");
if (entry.Kind == ScheduleEntryKind.Experiment &&
!IsExperimentRoom(classroom.RoomType))
Fail(entry, $"实验课不能安排在“{classroom.RoomType}”类型的场地");
if (constraint?.RequiredCampusId is Guid campusId &&
classroom.Building!.CampusId != campusId)
Fail(entry, "所选教室不在指定校区");
@@ -328,6 +305,12 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
.Select(int.Parse)
.ToHashSet();
private static bool IsExperimentRoom(string roomType) =>
roomType.Contains("实验", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("实训", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("机房", StringComparison.OrdinalIgnoreCase) ||
roomType.Contains("语音", StringComparison.OrdinalIgnoreCase);
[DoesNotReturn]
private static void Fail(ScheduleEntry entry, string message)
{
@@ -16,10 +16,35 @@ public static class TeachingTaskHours
public static int TargetHours(
Course course,
TeachingTaskSchedulingMode schedulingMode) =>
schedulingMode == TeachingTaskSchedulingMode.Flexible
? course.TotalHours
course.TotalHours;
public static int TargetHours(Course course, ScheduleEntryKind kind) =>
kind == ScheduleEntryKind.Experiment
? Math.Max(0, course.PracticeHours)
: RegularScheduleHours(course);
public static int ScheduledHours(ScheduleEntry entry) =>
ScheduledHours(
entry.StartWeek,
entry.EndWeek,
entry.WeekPattern,
entry.PeriodCount);
public static int ScheduledHours(
int startWeek,
int endWeek,
WeekPattern weekPattern,
int periodCount)
{
if (endWeek < startWeek || periodCount <= 0) return 0;
var occurrences = Enumerable.Range(startWeek, endWeek - startWeek + 1)
.Count(week =>
weekPattern == WeekPattern.All ||
weekPattern == WeekPattern.Odd && week % 2 == 1 ||
weekPattern == WeekPattern.Even && week % 2 == 0);
return occurrences * periodCount;
}
public static bool TryResolveRegularWeeklyHours(
Course course,
int startWeek,
@@ -62,14 +87,8 @@ public static class TeachingTaskHours
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
{
if (targetHours == 0)
{
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
}
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
$"{course.PracticeHours};理论课和实验课均应进入课表。当前第 " +
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
$"{plannedHours} 学时。请调整授课周次或周学时。";
}
@@ -28,6 +28,7 @@ public sealed class TimetableDataService(AppDbContext db)
allowUnpublishedPlan,
cancellationToken);
var slots = await db.ScheduleTimeSlots.AsNoTracking()
.TagWith("Timetable.LoadTimeSlots")
.Where(x => x.AcademicTermId == term.Id && x.IsEnabled)
.OrderBy(x => x.PeriodNumber)
.Select(x => new TimetableSlotDto(
@@ -76,6 +77,7 @@ public sealed class TimetableDataService(AppDbContext db)
}
entries = await source
.TagWith("Timetable.LoadScheduleEntries")
.OrderBy(x => x.DayOfWeek)
.ThenBy(x => x.StartPeriod)
.ThenBy(x => x.TeachingTask!.Course!.Code)
@@ -104,7 +106,14 @@ public sealed class TimetableDataService(AppDbContext db)
false,
null,
null,
x.UpdatedAt))
x.UpdatedAt,
false,
null,
null,
null,
null,
null,
x.Kind))
.ToListAsync(cancellationToken);
}
@@ -283,6 +292,7 @@ public sealed class TimetableDataService(AppDbContext db)
}
return await source
.TagWith("Timetable.LoadFlexibleCourses")
.OrderBy(x => x.Course!.Code)
.ThenBy(x => x.TaskNumber)
.Select(x => new FlexibleCourseDto(
@@ -326,9 +336,11 @@ public sealed class TimetableDataService(AppDbContext db)
legacyQuery, resourceType, resourceId, studentId);
var legacySessions = await legacyQuery
.TagWith("Timetable.LoadLegacyExamEntries")
.OrderBy(x => x.ExamDate)
.ThenBy(x => x.StartPeriod)
.Select(x => new ExamSessionProjection(
x.Id,
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
@@ -356,71 +368,58 @@ public sealed class TimetableDataService(AppDbContext db)
MapToEntryDto(x, slotLookup)));
// ── Mixed-room sessions (ExamRoomAssignment) ──
// Load all mixed rooms for this term into memory, then filter
var allMixedRooms = await db.ExamRooms.AsNoTracking()
.Where(room =>
room.ExamPlan!.AcademicTermId == academicTermId &&
room.ExamPlan.Status == ExamPlanStatus.Published)
.Include(room => room.ExamPlan)
.Include(room => room.Classroom)
.ThenInclude(c => c!.Building)
.ThenInclude(b => b!.Campus)
.Include(room => room.Invigilators)
.ThenInclude(i => i.Teacher)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Course)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Teachers)
.ThenInclude(tt => tt.Teacher)
.Include(room => room.SessionLinks)
.ThenInclude(link => link.ExamSession)
.ThenInclude(s => s!.TeachingTask)
.ThenInclude(t => t!.Classes)
.ThenInclude(tc => tc.AdministrativeClass)
.Include(room => room.Seats)
// Start from room/session links so the resource predicate stays in SQL.
// Loading every room, seat and roster for the term made a single
// timetable request scale with the entire exam plan.
var mixedQuery = db.ExamRoomSessions.AsNoTracking()
.Where(link =>
link.ExamRoom!.ExamPlan!.AcademicTermId == academicTermId &&
link.ExamRoom.ExamPlan.Status == ExamPlanStatus.Published);
mixedQuery = ApplyMixedResourceFilter(
mixedQuery, resourceType, resourceId, studentId);
var mixedSessions = await mixedQuery
.TagWith("Timetable.LoadMixedExamEntries")
.AsSplitQuery()
.OrderBy(link => link.ExamRoom!.ExamDate)
.ThenBy(link => link.ExamRoom!.StartPeriod)
.ThenBy(link => link.ExamSession!.TeachingTask!.Course!.Code)
.Select(link => new ExamSessionProjection(
link.ExamSessionId,
link.ExamRoomId,
link.ExamSession!.TeachingTaskId,
link.ExamSession.TeachingTask!.TaskNumber,
link.ExamSession.TeachingTask.Name,
link.ExamSession.TeachingTask.Course!.Code,
link.ExamSession.TeachingTask.Course.Name,
link.ExamSession.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name).ToList(),
link.ExamSession.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name).ToList(),
link.ExamRoom!.Classroom!.Name,
link.ExamRoom.Classroom.Building!.Name,
link.ExamRoom.Classroom.Building.Campus!.Name,
link.ExamRoom.ExamDate,
link.ExamRoom.StartPeriod,
link.ExamRoom.PeriodCount,
link.ExamRoom.ExamPlan!.Name,
link.ExamRoom.Invigilators
.Select(i => i.Teacher!.Name).ToList(),
null,
link.ExamRoom.UpdatedAt))
.ToListAsync(cancellationToken);
foreach (var room in allMixedRooms)
if (resourceType == TimetableResourceType.Class && !studentId.HasValue)
{
foreach (var link in room.SessionLinks)
{
var session = link.ExamSession;
if (session == null || session.TeachingTask == null) continue;
// Apply resource filter in memory
if (!MatchesMixedResource(
room, session, resourceType, resourceId, studentId))
continue;
result.Add(MapToEntryDto(new ExamSessionProjection(
room.Id,
session.TeachingTaskId,
session.TeachingTask.TaskNumber,
session.TeachingTask.Name,
session.TeachingTask.Course!.Code,
session.TeachingTask.Course.Name,
session.TeachingTask.Teachers
.OrderByDescending(t => t.IsPrimary)
.Select(t => t.Teacher!.Name).ToList(),
session.TeachingTask.Classes
.Select(c => c.AdministrativeClass!.Name).ToList(),
room.Classroom!.Name,
room.Classroom.Building!.Name,
room.Classroom.Building.Campus!.Name,
room.ExamDate,
room.StartPeriod,
room.PeriodCount,
room.ExamPlan!.Name,
room.Invigilators
.Select(i => i.Teacher!.Name).ToList(),
null,
room.UpdatedAt), slotLookup));
}
result.AddRange(mixedSessions
.GroupBy(x => x.ExamSessionId)
.Select(group => MapClassExamEntryDto(group, slotLookup)));
}
else
{
result.AddRange(mixedSessions.Select(x =>
MapToEntryDto(x, slotLookup)));
}
return result;
@@ -439,6 +438,7 @@ public sealed class TimetableDataService(AppDbContext db)
db,
studentId.Value);
var sessions = await db.ExperimentSessions.AsNoTracking()
.TagWith("Timetable.LoadExperimentEntries")
.AsSplitQuery()
.Where(x =>
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
@@ -535,9 +535,8 @@ public sealed class TimetableDataService(AppDbContext db)
return date.AddDays(1 - dayOfWeek);
}
private static bool MatchesMixedResource(
ExamRoomAssignment room,
ExamSession session,
private static IQueryable<ExamRoomSession> ApplyMixedResourceFilter(
IQueryable<ExamRoomSession> source,
TimetableResourceType resourceType,
Guid resourceId,
Guid? studentId)
@@ -545,20 +544,27 @@ public sealed class TimetableDataService(AppDbContext db)
switch (resourceType)
{
case TimetableResourceType.Classroom:
return room.ClassroomId == resourceId;
return source.Where(link =>
link.ExamRoom!.ClassroomId == resourceId);
case TimetableResourceType.Teacher:
return room.Invigilators.Any(i => i.TeacherId == resourceId) ||
session.TeachingTask!.Teachers.Any(
t => t.TeacherId == resourceId);
return source.Where(link =>
link.ExamRoom!.Invigilators.Any(i =>
i.TeacherId == resourceId) ||
link.ExamSession!.TeachingTask!.Teachers.Any(t =>
t.TeacherId == resourceId));
case TimetableResourceType.Class:
if (studentId.HasValue)
return session.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId) ||
room.Seats.Any(s => s.StudentId == studentId.Value);
return session.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId);
{
return source.Where(link =>
link.ExamRoom!.Seats.Any(s =>
s.StudentId == studentId.Value &&
s.ExamSessionId == link.ExamSessionId));
}
return source.Where(link =>
link.ExamSession!.TeachingTask!.Classes.Any(c =>
c.AdministrativeClassId == resourceId));
default:
return true;
return source;
}
}
@@ -608,7 +614,7 @@ public sealed class TimetableDataService(AppDbContext db)
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
: x.TeacherNames;
return new TimetableEntryDto(
x.Id,
x.ExamRoomId,
x.TeachingTaskId,
x.TaskNumber,
x.TaskName,
@@ -631,8 +637,42 @@ public sealed class TimetableDataService(AppDbContext db)
x.UpdatedAt);
}
private static TimetableEntryDto MapClassExamEntryDto(
IEnumerable<ExamSessionProjection> sessions,
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
{
var rooms = sessions.ToList();
var first = rooms[0];
var roomCount = rooms
.Select(x => x.ExamRoomId)
.Distinct()
.Count();
var buildingNames = rooms
.Select(x => x.BuildingName)
.Distinct()
.ToList();
var campusNames = rooms
.Select(x => x.CampusName)
.Distinct()
.ToList();
var entry = MapToEntryDto(first, slotLookup);
return entry with
{
Id = first.ExamSessionId,
TeacherNames = first.TeacherNames,
ClassroomName = roomCount == 1
? first.ClassroomName
: $"分散至 {roomCount} 个考场",
BuildingName = buildingNames.Count == 1 ? buildingNames[0] : null,
CampusName = campusNames.Count == 1 ? campusNames[0] : null,
UpdatedAt = rooms.Max(x => x.UpdatedAt),
ExamRoomCount = roomCount
};
}
private sealed record ExamSessionProjection(
Guid Id,
Guid ExamSessionId,
Guid ExamRoomId,
Guid TeachingTaskId,
string TaskNumber,
string TaskName,
@@ -762,7 +802,9 @@ public sealed record TimetableEntryDto(
string? ExperimentProjectCode = null,
string? ExperimentProjectName = null,
DateOnly? ExperimentDate = null,
ExperimentArrangementMode? ExperimentArrangementMode = null);
ExperimentArrangementMode? ExperimentArrangementMode = null,
ScheduleEntryKind Kind = ScheduleEntryKind.Lecture,
int ExamRoomCount = 1);
public sealed record FlexibleCourseDto(
Guid Id,
@@ -173,7 +173,7 @@ public static class TimetableExcelExporter
$"{Location(entry)}\n" +
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
: $"{entry.CourseName}\n" +
: $"{(entry.Kind == Domain.Academic.ScheduleEntryKind.Experiment ? "" : "")}{entry.CourseName}\n" +
$"{string.Join('、', entry.TeacherNames)}\n" +
$"{Location(entry)}\n" +
$"{entry.StartWeek}-{entry.EndWeek} 周";
+6
View File
@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>2.2.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
@@ -31,6 +32,11 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="SkiaSharp" Version="3.119.2" />
+89 -1
View File
@@ -7,6 +7,7 @@ using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Persistence;
@@ -19,6 +20,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Threading.RateLimiting;
EnvironmentFile.Load();
@@ -78,6 +82,12 @@ var backgroundJobOptions = builder.Configuration
var operationsOptions = builder.Configuration
.GetSection(OperationsOptions.SectionName)
.Get<OperationsOptions>() ?? new OperationsOptions();
var observabilityOptions = builder.Configuration
.GetSection(ObservabilityOptions.SectionName)
.Get<ObservabilityOptions>() ?? new ObservabilityOptions();
var performanceReportingOptions = builder.Configuration
.GetSection(PerformanceReportingOptions.SectionName)
.Get<PerformanceReportingOptions>() ?? new PerformanceReportingOptions();
var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
@@ -110,6 +120,46 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
}
if (string.IsNullOrWhiteSpace(observabilityOptions.ServiceName) ||
observabilityOptions.ServiceName.Length > 100 ||
observabilityOptions.SlowQueryThresholdMilliseconds is < 1 or > 60000 ||
observabilityOptions.MaximumSqlTextLength is < 256 or > 20000)
{
throw new InvalidOperationException(
"Observability 服务名、慢查询阈值或 SQL 文本长度超出允许范围。");
}
if (performanceReportingOptions.CacheSeconds is < 5 or > 300 ||
performanceReportingOptions.TimeoutSeconds is < 1 or > 60 ||
performanceReportingOptions.BearerToken.Length > 8000 ||
!PerformanceReportingOptions.IsMetricOrLabelName(
performanceReportingOptions.ServiceLabel) ||
!PerformanceReportingOptions.IsMetricOrLabelName(
performanceReportingOptions.RequestDurationMetric) ||
!PerformanceReportingOptions.IsMetricOrLabelName(
performanceReportingOptions.DatabaseDurationMetric) ||
!PerformanceReportingOptions.IsMetricOrLabelName(
performanceReportingOptions.SlowDatabaseMetric) ||
!PerformanceReportingOptions.IsMetricOrLabelName(
performanceReportingOptions.FailedDatabaseMetric) ||
(performanceReportingOptions.Enabled &&
!IsHttpUrl(performanceReportingOptions.PrometheusBaseUrl)) ||
(!string.IsNullOrWhiteSpace(performanceReportingOptions.GrafanaBaseUrl) &&
!IsHttpUrl(performanceReportingOptions.GrafanaBaseUrl)))
{
throw new InvalidOperationException(
"PerformanceReporting 数据源、超时、缓存或指标名称配置无效。");
}
var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];
if (!string.IsNullOrWhiteSpace(otlpEndpoint) &&
(!Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var parsedOtlpEndpoint) ||
parsedOtlpEndpoint.Scheme is not ("http" or "https")))
{
throw new InvalidOperationException(
"OTEL_EXPORTER_OTLP_ENDPOINT 必须是有效的 HTTP 或 HTTPS 绝对地址。");
}
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
@@ -193,11 +243,23 @@ builder.Services.AddSingleton(cacheOptions);
builder.Services.AddSingleton(officialDocumentOptions);
builder.Services.AddSingleton(backgroundJobOptions);
builder.Services.AddSingleton(operationsOptions);
builder.Services.AddSingleton(observabilityOptions);
builder.Services.AddSingleton(performanceReportingOptions);
builder.Services.AddSingleton(rabbitMqOptions);
builder.Services.AddSingleton<DatabaseCommandTelemetryInterceptor>();
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<PerformanceReportService>((services, client) =>
{
var reporting = services.GetRequiredService<PerformanceReportingOptions>();
client.Timeout = TimeSpan.FromSeconds(reporting.TimeoutSeconds);
});
builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>(options =>
builder.Services.AddDbContextPool<AppDbContext>((services, options) =>
{
options.AddInterceptors(
services.GetRequiredService<DatabaseCommandTelemetryInterceptor>());
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
{
var sqliteConnectionString = builder.Configuration.GetConnectionString("SQLite")
@@ -240,6 +302,28 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
});
});
if (observabilityOptions.Enabled &&
!string.IsNullOrWhiteSpace(otlpEndpoint))
{
builder.Services
.AddOpenTelemetry()
.ConfigureResource(resource =>
resource.AddService(observabilityOptions.ServiceName))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(DatabaseCommandTelemetryInterceptor.MeterName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
options.Filter = context =>
!context.Request.Path.StartsWithSegments("/health/live"))
.AddHttpClientInstrumentation()
.AddSource(DatabaseCommandTelemetryInterceptor.ActivitySourceName))
.WithMetrics(metrics => metrics.AddOtlpExporter())
.WithTracing(tracing => tracing.AddOtlpExporter());
}
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
{
@@ -616,4 +700,8 @@ static async Task<IResult> CheckMessagingHealthAsync(
}
}
static bool IsHttpUrl(string value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https";
public partial class Program;
+20
View File
@@ -19,6 +19,26 @@
"AnalyticsLocalExpirationSeconds": 30,
"MaximumPayloadKilobytes": 2048
},
"Observability": {
"Enabled": true,
"ServiceName": "jiaowu-api",
"SlowQueryThresholdMilliseconds": 500,
"IncludeSqlText": false,
"MaximumSqlTextLength": 2000
},
"PerformanceReporting": {
"Enabled": false,
"PrometheusBaseUrl": "",
"BearerToken": "",
"GrafanaBaseUrl": "",
"CacheSeconds": 30,
"TimeoutSeconds": 10,
"ServiceLabel": "service_name",
"RequestDurationMetric": "http_server_request_duration_seconds",
"DatabaseDurationMetric": "jiaowu_db_command_duration_milliseconds",
"SlowDatabaseMetric": "jiaowu_db_command_slow_total",
"FailedDatabaseMetric": "jiaowu_db_command_failed_total"
},
"Operations": {
"BackupDirectory": "data/backups",
"BackupWarningHours": 24,
@@ -1,6 +1,7 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
@@ -157,6 +158,21 @@ public sealed class AutomaticScheduleGeneratorTests
EndDate = new DateOnly(2027, 1, 15)
};
var college = new College { Code = "LAB", Name = "实验学院" };
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
var building = new Building
{
Code = "LAB-BUILDING",
Name = "实验楼",
Campus = campus
};
var laboratory = new Classroom
{
Code = "LAB-101",
Name = "实验室 101",
Building = building,
Capacity = 40,
RoomType = "实验室"
};
var course = new Course
{
Code = "LAB-01",
@@ -185,7 +201,7 @@ public sealed class AutomaticScheduleGeneratorTests
Name = "实验学时拆分测试",
Version = "V1"
};
db.AddRange(term, college, course, task, plan);
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
@@ -203,22 +219,22 @@ public sealed class AutomaticScheduleGeneratorTests
StartsAt = new TimeOnly(8, 55),
EndsAt = new TimeOnly(9, 40)
});
db.TeachingTaskScheduleConstraints.Add(
new TeachingTaskScheduleConstraint
{
TeachingTask = task,
RequiresClassroom = false
});
await db.SaveChangesAsync();
var result = await new AutomaticScheduleGenerator(db)
.GenerateAsync(plan, CancellationToken.None);
Assert.Equal(1, result.CreatedEntries);
Assert.Equal(2, result.CreatedEntries);
Assert.Equal(1, result.CompletedTasks);
Assert.Equal(
1,
(await db.ScheduleEntries.SingleAsync()).PeriodCount);
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
Assert.Equal(2, entries.Count);
Assert.Contains(entries, x =>
x.Kind == ScheduleEntryKind.Lecture &&
TeachingTaskHours.ScheduledHours(x) == 16);
Assert.Contains(entries, x =>
x.Kind == ScheduleEntryKind.Experiment &&
x.ClassroomId == laboratory.Id &&
TeachingTaskHours.ScheduledHours(x) == 16);
}
[Fact]
@@ -0,0 +1,88 @@
using System.Diagnostics.Metrics;
using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class DatabaseCommandTelemetryInterceptorTests
{
[Fact]
public void Query_name_uses_tag_without_exposing_statement_text()
{
const string sql =
"-- Timetable.LoadMixedExamEntries\n" +
"SELECT * FROM ExamRooms WHERE SecretValue = @p0";
var queryName =
DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
Assert.Equal("Timetable.LoadMixedExamEntries", queryName);
Assert.DoesNotContain("SecretValue", queryName);
Assert.DoesNotContain("@p0", queryName);
}
[Fact]
public void Untagged_query_name_is_stable_hash_not_statement_text()
{
const string sql =
"SELECT * FROM Students WHERE StudentNumber = @studentNumber";
var first = DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
var second = DatabaseCommandTelemetryInterceptor.GetQueryName(sql);
Assert.Equal(first, second);
Assert.StartsWith("select:", first);
Assert.DoesNotContain("Students", first);
Assert.DoesNotContain("StudentNumber", first);
}
[Fact]
public async Task Ef_command_records_duration_metric()
{
await using var connection =
new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var setupOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using (var setupDb = new AppDbContext(setupOptions))
await setupDb.Database.EnsureCreatedAsync();
double? recordedDuration = null;
using var listener = new MeterListener
{
InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name ==
DatabaseCommandTelemetryInterceptor.MeterName &&
instrument.Name == "jiaowu.db.command.duration")
{
meterListener.EnableMeasurementEvents(instrument);
}
}
};
listener.SetMeasurementEventCallback<double>(
(_, measurement, _, _) => recordedDuration = measurement);
listener.Start();
var interceptor = new DatabaseCommandTelemetryInterceptor(
new ObservabilityOptions(),
NullLogger<DatabaseCommandTelemetryInterceptor>.Instance);
var queryOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.AddInterceptors(interceptor)
.Options;
await using var db = new AppDbContext(queryOptions);
await db.AcademicTerms
.TagWith("Observability.Tests.TermCount")
.CountAsync();
Assert.NotNull(recordedDuration);
Assert.True(recordedDuration >= 0);
}
}
@@ -1,6 +1,7 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Timetables;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -55,6 +56,48 @@ public sealed class ExamArrangementServiceTests
Assert.Contains("1个教学班场次处理完成", result.Message);
Assert.Contains("1名监考教师", result.Message);
Assert.Contains("程序设计", result.Message);
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
plan.Status = ExamPlanStatus.Published;
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var timetableService = new TimetableDataService(db);
var classroomTimetable = await timetableService.BuildAsync(
TimetableResourceType.Classroom,
seed.LargeClassroom.Id,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
var unrelatedClassroomTimetable = await timetableService.BuildAsync(
TimetableResourceType.Classroom,
seed.SmallClassroom.Id,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
var invigilatorTimetable = await timetableService.BuildAsync(
TimetableResourceType.Teacher,
room.Invigilators.Single().TeacherId,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
Assert.Equal(
"程序设计",
Assert.Single(classroomTimetable!.ExamEntries).CourseName);
Assert.Empty(unrelatedClassroomTimetable!.ExamEntries);
Assert.Contains(
"监考:监考教师",
Assert.Single(invigilatorTimetable!.ExamEntries).TeacherNames);
}
[Fact]
@@ -108,6 +151,46 @@ public sealed class ExamArrangementServiceTests
Assert.Equal(6, await db.ExamSeats.CountAsync());
Assert.Contains("2个教学班混排至2个考场", result.Message);
Assert.Contains("自动包含同组1个场次", result.Message);
plan = await db.ExamPlans.SingleAsync(x => x.Id == plan.Id);
plan.Status = ExamPlanStatus.Published;
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var student = await db.Students.AsNoTracking()
.OrderBy(x => x.StudentNumber)
.FirstAsync();
var timetableService = new TimetableDataService(db);
var classTimetable = await timetableService.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
seed.Term.Id,
null,
false,
null,
null,
CancellationToken.None);
var studentTimetable = await timetableService.BuildAsync(
TimetableResourceType.Class,
student.AdministrativeClassId,
seed.Term.Id,
null,
false,
student.Id,
new TimetableStudentDto(student.StudentNumber, student.Name),
CancellationToken.None);
var classExam = Assert.Single(classTimetable!.ExamEntries);
Assert.Equal(first.Id, classExam.Id);
Assert.Equal(2, classExam.ExamRoomCount);
Assert.Equal("分散至 2 个考场", classExam.ClassroomName);
Assert.DoesNotContain("监考:", classExam.TeacherNames);
var studentExam = Assert.Single(studentTimetable!.ExamEntries);
Assert.Equal(1, studentExam.ExamRoomCount);
Assert.Contains(
studentExam.ClassroomName,
new[] { seed.LargeClassroom.Name, seed.SmallClassroom.Name });
}
[Fact]
@@ -12,6 +12,112 @@ namespace Jiaowu.Api.Tests;
public sealed class ExperimentsControllerTests
{
[Fact]
public async Task BatchProjects_CreateSameDefinitionForSameCourseTasks()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
var result = await controller.CreateProjects(
fixture.BatchProjectRequest(
ExperimentArrangementMode.Centralized),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
var projects = await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTaskId)
.ToListAsync();
Assert.Equal(2, projects.Count);
Assert.Equal(2, projects.Select(x => x.TeachingTaskId).Distinct().Count());
Assert.All(projects, project =>
{
Assert.Equal("LAB-BATCH", project.Code);
Assert.Equal("公共实验任务", project.Name);
Assert.Equal(ExperimentProjectStatus.Draft, project.Status);
});
}
[Fact]
public async Task BatchSessions_RollsBackAllRowsWhenOneConflicts()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProjects(
fixture.BatchProjectRequest(
ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var projectIds = await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTaskId)
.Select(x => x.Id)
.ToListAsync();
var result = await controller.CreateSessions(
new ExperimentSessionBatchRequest(
[
new ExperimentSessionBatchItem(
projectIds[0],
fixture.Classroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null),
new ExperimentSessionBatchItem(
projectIds[1],
fixture.Classroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null)
]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(result);
fixture.Db.ChangeTracker.Clear();
Assert.Empty(await fixture.Db.ExperimentSessions.ToListAsync());
}
[Fact]
public async Task BatchSessions_CreatesRowsAtomicallyWhenAllAreValid()
{
await using var fixture = await ExperimentFixture.CreateAsync();
var controller = fixture.Controller(fixture.ManagerScope);
await controller.CreateProjects(
fixture.BatchProjectRequest(
ExperimentArrangementMode.SelfScheduled),
CancellationToken.None);
var projectIds = await fixture.Db.ExperimentProjects
.OrderBy(x => x.TeachingTaskId)
.Select(x => x.Id)
.ToListAsync();
var result = await controller.CreateSessions(
new ExperimentSessionBatchRequest(
[
new ExperimentSessionBatchItem(
projectIds[0],
fixture.Classroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null),
new ExperimentSessionBatchItem(
projectIds[1],
fixture.SecondClassroom.Id,
fixture.Term.StartDate,
1,
2,
20,
null)
]),
CancellationToken.None);
Assert.IsType<CreatedResult>(result);
Assert.Equal(2, await fixture.Db.ExperimentSessions.CountAsync());
}
[Fact]
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
{
@@ -421,6 +527,15 @@ public sealed class ExperimentsControllerTests
}
]
};
var secondTask = new TeachingTask
{
TaskNumber = "2099-1-CSLAB-02",
Name = "系统实验教学班 02",
AcademicTermId = term.Id,
CourseId = course.Id,
Capacity = 40,
Status = TeachingTaskStatus.Published
};
db.AddRange(
manager,
studentUser,
@@ -435,7 +550,8 @@ public sealed class ExperimentsControllerTests
teacher,
term,
course,
task);
task,
secondTask);
for (var period = 1; period <= 12; period++)
{
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
@@ -486,6 +602,21 @@ public sealed class ExperimentsControllerTests
Term.StartDate,
Term.StartDate.AddDays(14));
public ExperimentProjectBatchRequest BatchProjectRequest(
ExperimentArrangementMode mode) =>
new(
Db.TeachingTasks
.OrderBy(x => x.TaskNumber)
.Select(x => x.Id)
.ToList(),
"LAB-BATCH",
"公共实验任务",
mode,
"多个教学任务共用的实验内容。",
"携带校园卡。",
Term.StartDate,
Term.StartDate.AddDays(14));
public ExperimentSessionRequest SessionRequest(
int startPeriod,
int periodCount,
@@ -7,6 +7,7 @@ using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Observability;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
@@ -201,6 +202,7 @@ public sealed class OperationsControllerTests
var services = new ServiceCollection()
.AddLogging()
.AddMemoryCache()
.BuildServiceProvider();
var logger = services.GetRequiredService<
ILogger<DatabaseBackupService>>();
@@ -224,10 +226,19 @@ public sealed class OperationsControllerTests
configuration,
environment,
logger);
var performance = new PerformanceReportService(
new HttpClient(),
services.GetRequiredService<
Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
new PerformanceReportingOptions(),
new ObservabilityOptions(),
services.GetRequiredService<
ILogger<PerformanceReportService>>());
var controller = new OperationsController(
db,
health,
backups,
performance,
operationsOptions);
return new OperationsFixture(services, db, controller, backups);
}
@@ -0,0 +1,188 @@
using System.Net;
using System.Text;
using Jiaowu.Api.Infrastructure.Observability;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
namespace Jiaowu.Api.Tests;
public sealed class PerformanceReportServiceTests
{
[Fact]
public async Task Configured_source_returns_cached_native_report()
{
var handler = new PrometheusHandler();
using var httpClient = new HttpClient(handler);
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new PerformanceReportService(
httpClient,
cache,
new PerformanceReportingOptions
{
Enabled = true,
PrometheusBaseUrl = "https://prometheus.test/",
BearerToken = "read-only-token",
GrafanaBaseUrl = "https://grafana.test/",
CacheSeconds = 30
},
new ObservabilityOptions { ServiceName = "jiaowu-api" },
NullLogger<PerformanceReportService>.Instance);
var first = await service.GetAsync("1h", CancellationToken.None);
var second = await service.GetAsync("1h", CancellationToken.None);
Assert.Equal("ready", first.Status);
Assert.Equal("prometheus", first.DataSource);
Assert.Equal("https://grafana.test/", first.DashboardUrl);
Assert.NotNull(first.Headline);
Assert.Equal(5, first.Headline.RequestCount);
Assert.Equal(2, first.Timeline.Count);
Assert.Equal(
"/api/timetables/classes/{classId}",
Assert.Single(first.Endpoints).Name);
Assert.Equal(
"Timetable.LoadScheduleEntries",
Assert.Single(first.DatabaseQueries).Name);
Assert.Same(first, second);
Assert.Equal(14, handler.RequestCount);
Assert.True(handler.AllRequestsAuthenticated);
}
[Fact]
public async Task Missing_source_returns_directed_empty_state()
{
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new PerformanceReportService(
new HttpClient(new RejectingHandler()),
cache,
new PerformanceReportingOptions(),
new ObservabilityOptions(),
NullLogger<PerformanceReportService>.Instance);
var report = await service.GetAsync("24h", CancellationToken.None);
Assert.Equal("not_configured", report.Status);
Assert.Contains("Prometheus", report.Detail);
Assert.Empty(report.Timeline);
}
[Fact]
public async Task Unsupported_range_is_rejected_before_querying_source()
{
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new PerformanceReportService(
new HttpClient(new RejectingHandler()),
cache,
new PerformanceReportingOptions(),
new ObservabilityOptions(),
NullLogger<PerformanceReportService>.Instance);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
service.GetAsync("30d", CancellationToken.None));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
service.GetAsync(null, CancellationToken.None));
}
[Fact]
public async Task Source_timeout_returns_unavailable_report()
{
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new PerformanceReportService(
new HttpClient(new TimeoutHandler()),
cache,
new PerformanceReportingOptions
{
Enabled = true,
PrometheusBaseUrl = "https://prometheus.test/"
},
new ObservabilityOptions(),
NullLogger<PerformanceReportService>.Instance);
var report = await service.GetAsync("1h", CancellationToken.None);
Assert.Equal("unavailable", report.Status);
Assert.Contains("暂时不可用", report.Detail);
}
private sealed class PrometheusHandler : HttpMessageHandler
{
private int requestCount;
private int authenticatedCount;
public int RequestCount => requestCount;
public bool AllRequestsAuthenticated =>
authenticatedCount == requestCount;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref requestCount);
if (request.Headers.Authorization?.Scheme == "Bearer" &&
request.Headers.Authorization.Parameter == "read-only-token")
{
Interlocked.Increment(ref authenticatedCount);
}
var isRange = request.RequestUri!.AbsolutePath.EndsWith(
"/query_range",
StringComparison.Ordinal);
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var json = isRange
? $$"""
{
"status": "success",
"data": {
"resultType": "matrix",
"result": [{
"metric": {},
"values": [
[{{now - 60}}, "2"],
[{{now}}, "3"]
]
}]
}
}
"""
: $$"""
{
"status": "success",
"data": {
"resultType": "vector",
"result": [{
"metric": {
"http_route": "/api/timetables/classes/{classId}",
"db_query_name": "Timetable.LoadScheduleEntries"
},
"value": [{{now}}, "5"]
}]
}
}
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
json,
Encoding.UTF8,
"application/json")
});
}
}
private sealed class RejectingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
throw new InvalidOperationException(
"未配置时不应访问外部数据源。");
}
private sealed class TimeoutHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
throw new TaskCanceledException("Prometheus query timed out.");
}
}
@@ -32,40 +32,41 @@ public sealed class SchedulePublishJobProcessorTests
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
Assert.Null(result.ActiveAcademicTermId);
Assert.Equal("检查未通过", result.CurrentStep);
Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
Assert.Contains("理论课应安排 32 学时,当前已安排 16 学时", result.ErrorMessage);
Assert.Null(result.PublishedAt);
}
[Fact]
public async Task Processor_excludes_practice_hours_for_existing_tasks()
public async Task Processor_requires_experiment_hours_in_regular_schedule()
{
var result = await RunPublishAsync(
weeklyHours: 2,
scheduledHours: 1,
practiceHours: 16);
practiceHours: 16,
experimentScheduledHours: 1);
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
}
[Fact]
public async Task Processor_rejects_practice_hours_already_added_to_draft()
public async Task Processor_rejects_missing_experiment_hours()
{
var result = await RunPublishAsync(
weeklyHours: 2,
scheduledHours: 2,
scheduledHours: 1,
practiceHours: 16);
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
Assert.Contains("普通课表应为 1 学时", result.ErrorMessage);
Assert.Contains("删除已包含的实践学时", result.ErrorMessage);
Assert.Contains("实验课应安排 16 学时,当前已安排 0 学时", result.ErrorMessage);
}
private static async Task<PublishResult> RunPublishAsync(
int weeklyHours,
int scheduledHours,
int practiceHours = 0)
int practiceHours = 0,
int experimentScheduledHours = 0)
{
var databasePath = Path.Combine(
Path.GetTempPath(),
@@ -97,6 +98,21 @@ public sealed class SchedulePublishJobProcessorTests
EndDate = new DateOnly(2027, 1, 15)
};
var college = new College { Code = "PUB", Name = "发布测试学院" };
var campus = new Campus { Code = "PUB-CAMPUS", Name = "发布测试校区" };
var building = new Building
{
Code = "PUB-BUILDING",
Name = "实验楼",
Campus = campus
};
var laboratory = new Classroom
{
Code = "PUB-LAB",
Name = "发布测试实验室",
Building = building,
Capacity = 80,
RoomType = "实验室"
};
var course = new Course
{
Code = "PUB-01",
@@ -135,6 +151,21 @@ public sealed class SchedulePublishJobProcessorTests
EndWeek = 16,
WeekPattern = WeekPattern.All
});
if (experimentScheduledHours > 0)
{
plan.Entries.Add(new ScheduleEntry
{
TeachingTask = task,
Kind = ScheduleEntryKind.Experiment,
Classroom = laboratory,
DayOfWeek = 2,
StartPeriod = 1,
PeriodCount = experimentScheduledHours,
StartWeek = 1,
EndWeek = 16,
WeekPattern = WeekPattern.All
});
}
var job = new SchedulePublishJob
{
SchedulePlan = plan,
@@ -143,7 +174,16 @@ public sealed class SchedulePublishJobProcessorTests
CurrentStep = "等待后台检查"
};
jobId = job.Id;
db.AddRange(term, college, course, task, plan, job);
db.AddRange(
term,
college,
campus,
building,
laboratory,
course,
task,
plan,
job);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
@@ -189,7 +189,7 @@ public sealed class ScheduleSettingsControllerTests
});
Assert.Contains("测试教师", json);
Assert.Contains(classroom.Id.ToString(), json);
Assert.Contains("\"WeeklyHours\":3", json);
Assert.Contains("\"WeeklyHours\":4", json);
Assert.Contains("\"CoursePracticeHours\":16", json);
}
@@ -13,7 +13,7 @@ namespace Jiaowu.Api.Tests;
public sealed class SchedulesControllerTests
{
[Fact]
public async Task Manual_entry_rejects_hours_reserved_for_experiments()
public async Task Manual_entry_adds_experiment_hours_to_regular_schedule()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
@@ -33,6 +33,21 @@ public sealed class SchedulesControllerTests
EndDate = new DateOnly(2027, 1, 17)
};
var college = new College { Code = "LAB", Name = "实验学院" };
var campus = new Campus { Code = "LAB-CAMPUS", Name = "实验校区" };
var building = new Building
{
Code = "LAB-BUILDING",
Name = "实验楼",
Campus = campus
};
var laboratory = new Classroom
{
Code = "LAB-201",
Name = "实验室 201",
Building = building,
Capacity = 40,
RoomType = "实验室"
};
var course = new Course
{
Code = "LAB-01",
@@ -71,7 +86,7 @@ public sealed class SchedulesControllerTests
EndWeek = 16,
WeekPattern = WeekPattern.All
});
db.AddRange(term, college, course, task, plan);
db.AddRange(term, college, campus, building, laboratory, course, task, plan);
db.ScheduleTimeSlots.AddRange(
new ScheduleTimeSlot
{
@@ -102,22 +117,22 @@ public sealed class SchedulesControllerTests
plan.Id,
new ScheduleEntryRequest(
task.Id,
null,
2,
laboratory.Id,
2,
1,
1,
1,
16,
WeekPattern.All,
null),
null,
ScheduleEntryKind.Experiment),
CancellationToken.None);
var problem = Assert.IsType<ObjectResult>(result);
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
Assert.Contains(
"实践学时请在实验管理中安排",
details.Detail);
Assert.Single(await db.ScheduleEntries.ToListAsync());
Assert.IsType<CreatedResult>(result);
var entries = await db.ScheduleEntries.OrderBy(x => x.Kind).ToListAsync();
Assert.Equal(2, entries.Count);
Assert.Equal(ScheduleEntryKind.Experiment, entries[1].Kind);
Assert.Equal(laboratory.Id, entries[1].ClassroomId);
}
[Fact]
@@ -0,0 +1,20 @@
using System.Reflection;
using Jiaowu.Api.Controllers;
namespace Jiaowu.Api.Tests;
public sealed class SystemControllerTests
{
[Fact]
public void GetVersion_ReturnsConfiguredApplicationVersion()
{
var response = new SystemController().GetVersion();
var configuredVersion = typeof(SystemController).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
.InformationalVersion
.Split('+', 2)[0];
Assert.Equal(configuredVersion, response.Version);
Assert.Matches(@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$", response.Version);
}
}
@@ -32,7 +32,7 @@ public sealed class TeachingTaskHoursTests
}
[Fact]
public void Standard_schedule_excludes_practice_hours()
public void Standard_schedule_includes_theory_and_experiment_hours()
{
var course = new Course
{
@@ -52,12 +52,12 @@ public sealed class TeachingTaskHoursTests
16,
out var weeklyHours));
Assert.Equal(3, weeklyHours);
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3));
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 4));
var result = TeachingTaskHours.Validate(course, 1, 16, 4);
var result = TeachingTaskHours.Validate(course, 1, 16, 3);
Assert.NotNull(result);
Assert.Contains("普通课表应安排 48 学时", result);
Assert.Contains("实践学时 16", result);
Assert.Contains("理论课和实验课均应进入课表", result);
Assert.Contains("共 48 学时", result);
}
[Fact]
@@ -79,8 +79,6 @@ public sealed class TeachingTaskHoursTests
16,
1,
TeachingTaskSchedulingMode.Flexible));
Assert.Contains(
"无需进入普通课表",
TeachingTaskHours.Validate(course, 1, 16, 1)!);
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 1));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"version": "2.2.0-rc1",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import SiteFooter from './components/SiteFooter.vue'
import SmartLaunchScreen from './components/SmartLaunchScreen.vue'
</script>
<template>
<SmartLaunchScreen />
<RouterView />
<SiteFooter />
</template>
+3
View File
@@ -42,6 +42,7 @@ declare module 'vue' {
ElResult: typeof import('element-plus/es')['ElResult']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -52,9 +53,11 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
PerformanceReportPanel: typeof import('./components/PerformanceReportPanel.vue')['default']
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SiteFooter: typeof import('./components/SiteFooter.vue')['default']
SmartLaunchScreen: typeof import('./components/SmartLaunchScreen.vue')['default']
}
export interface GlobalDirectives {
@@ -0,0 +1,762 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Connection, RefreshRight, TopRight } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import http, { apiErrorMessage } from '../api/http'
echarts.use([
LineChart,
GridComponent,
LegendComponent,
TooltipComponent,
CanvasRenderer,
])
type ReportStatus = 'ready' | 'not_configured' | 'unavailable'
type ReportRange = '15m' | '1h' | '24h' | '7d'
interface PerformanceHeadline {
requestCount?: number
requestP95Milliseconds?: number
serverErrorRatePercent?: number
databaseP95Milliseconds?: number
slowDatabaseCommandCount?: number
failedDatabaseCommandCount?: number
}
interface TimelinePoint {
timestamp: string
requestsPerSecond?: number
requestP95Milliseconds?: number
}
interface RankingItem {
name: string
p95Milliseconds?: number
requestCount?: number
exceptionalCount?: number
}
interface PerformanceReport {
status: ReportStatus
range: ReportRange
from?: string
to?: string
generatedAt: string
dataSource: string
dashboardUrl?: string
detail?: string
headline?: PerformanceHeadline
timeline: TimelinePoint[]
endpoints: RankingItem[]
databaseQueries: RankingItem[]
}
const rangeOptions: { value: ReportRange; label: string }[] = [
{ value: '15m', label: '15 分钟' },
{ value: '1h', label: '1 小时' },
{ value: '24h', label: '24 小时' },
{ value: '7d', label: '7 天' },
]
const range = ref<ReportRange>('1h')
const loading = ref(true)
const report = ref<PerformanceReport>()
const chartElement = ref<HTMLElement>()
let chart: echarts.ECharts | undefined
let resizeObserver: ResizeObserver | undefined
const hasSamples = computed(() =>
Boolean(report.value?.headline) &&
(report.value?.timeline.length ?? 0) > 0,
)
const operatingNote = computed(() => {
const headline = report.value?.headline
if (!headline) return '等待指标样本'
if ((headline.failedDatabaseCommandCount ?? 0) > 0)
return '存在失败的数据库命令'
if ((headline.serverErrorRatePercent ?? 0) >= 1)
return '服务端错误率需要关注'
if ((headline.slowDatabaseCommandCount ?? 0) > 0)
return '存在超过阈值的慢查询'
return '当前采样窗口内未见明显异常'
})
function formatNumber(value?: number, digits = 0) {
if (value == null || !Number.isFinite(value)) return '—'
return value.toLocaleString('zh-CN', {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
})
}
function formatTime(value?: string) {
if (!value) return '—'
return new Date(value).toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatAxisTime(value: string) {
return new Date(value).toLocaleString('zh-CN', {
hour12: false,
month: range.value === '7d' ? '2-digit' : undefined,
day: range.value === '7d' ? '2-digit' : undefined,
hour: '2-digit',
minute: '2-digit',
})
}
function metricWidth(value?: number, rows: RankingItem[] = []) {
if (value == null || value <= 0) return '0%'
const maximum = Math.max(
...rows.map((row) => row.p95Milliseconds ?? 0),
value,
)
return `${Math.max(4, value / maximum * 100)}%`
}
async function loadReport() {
loading.value = true
try {
const { data } = await http.get<PerformanceReport>(
'/operations/performance',
{ params: { range: range.value } },
)
report.value = data
await nextTick()
renderChart()
} catch (error) {
report.value = {
status: 'unavailable',
range: range.value,
generatedAt: new Date().toISOString(),
dataSource: 'prometheus',
detail: apiErrorMessage(error),
timeline: [],
endpoints: [],
databaseQueries: [],
}
ElMessage.error(apiErrorMessage(error))
disposeChart()
} finally {
loading.value = false
}
}
function renderChart() {
disposeChart()
if (!chartElement.value || !report.value?.timeline.length) return
chart = echarts.init(chartElement.value)
const times = report.value.timeline.map((point) =>
formatAxisTime(point.timestamp),
)
chart.setOption({
animationDuration: 420,
color: ['#1f7468', '#b47722'],
grid: { left: 50, right: 54, top: 54, bottom: 38 },
legend: {
top: 4,
left: 0,
itemWidth: 18,
itemHeight: 3,
textStyle: {
color: '#53636d',
fontFamily: '"Cascadia Mono", Consolas, monospace',
fontSize: 10,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(30, 41, 50, 0.94)',
borderWidth: 0,
textStyle: { color: '#fff', fontSize: 12 },
valueFormatter: (value: unknown) =>
typeof value === 'number' ? value.toFixed(2) : '—',
},
xAxis: {
type: 'category',
boundaryGap: false,
data: times,
axisLine: { lineStyle: { color: '#cbd4d7' } },
axisTick: { show: false },
axisLabel: { color: '#79878f', fontSize: 10, hideOverlap: true },
},
yAxis: [
{
type: 'value',
name: '请求 / 秒',
nameTextStyle: { color: '#63727b', fontSize: 10 },
splitLine: { lineStyle: { color: '#e8edef', type: 'dashed' } },
axisLabel: { color: '#79878f', fontSize: 10 },
},
{
type: 'value',
name: 'P95 / ms',
nameTextStyle: { color: '#8b6b36', fontSize: 10 },
splitLine: { show: false },
axisLabel: { color: '#8b6b36', fontSize: 10 },
},
],
series: [
{
name: '请求速率',
type: 'line',
smooth: 0.22,
symbol: 'none',
lineStyle: { width: 2 },
areaStyle: { color: 'rgba(31, 116, 104, 0.10)' },
data: report.value.timeline.map(
(point) => point.requestsPerSecond ?? null,
),
},
{
name: '接口 P95',
type: 'line',
yAxisIndex: 1,
smooth: 0.22,
symbol: 'none',
lineStyle: { width: 1.5 },
data: report.value.timeline.map(
(point) => point.requestP95Milliseconds ?? null,
),
},
],
})
resizeObserver = new ResizeObserver(() => chart?.resize())
resizeObserver.observe(chartElement.value)
}
function disposeChart() {
resizeObserver?.disconnect()
resizeObserver = undefined
chart?.dispose()
chart = undefined
}
watch(range, loadReport)
onMounted(loadReport)
onUnmounted(disposeChart)
</script>
<template>
<section class="performance-panel" aria-labelledby="performance-title">
<header class="performance-heading">
<div>
<span>PERFORMANCE RAIL / {{ report?.dataSource?.toUpperCase() || 'PROMETHEUS' }}</span>
<h3 id="performance-title">系统性能</h3>
<p>从接口进入数据库定位响应时间消耗在哪里</p>
</div>
<div class="performance-actions">
<div class="range-switch" aria-label="性能报表时间范围">
<button
v-for="item in rangeOptions"
:key="item.value"
type="button"
:class="{ active: range === item.value }"
:aria-pressed="range === item.value"
@click="range = item.value"
>
{{ item.label }}
</button>
</div>
<el-button
circle
:icon="RefreshRight"
:loading="loading"
aria-label="刷新性能报表"
@click="loadReport"
/>
</div>
</header>
<div v-if="loading && !report" class="performance-loading">
<el-skeleton :rows="5" animated />
</div>
<div
v-else-if="report?.status !== 'ready'"
:class="`is-${report?.status || 'unavailable'}`"
class="source-state"
>
<el-icon><Connection /></el-icon>
<div>
<strong>
{{ report?.status === 'not_configured' ? '等待连接性能数据源' : '性能数据暂不可用' }}
</strong>
<p>{{ report?.detail }}</p>
<small>
业务接口继续正常运行报表不会回退读取教务业务数据库
</small>
</div>
<a
v-if="report?.dashboardUrl"
:href="report.dashboardUrl"
target="_blank"
rel="noreferrer"
>
打开监控平台
<el-icon><TopRight /></el-icon>
</a>
</div>
<template v-else>
<div class="performance-strip">
<div class="strip-lead">
<span>采样结论</span>
<strong>{{ operatingNote }}</strong>
<small>
{{ formatTime(report.from) }} {{ formatTime(report.to) }}
</small>
</div>
<dl>
<div>
<dt>请求总量</dt>
<dd>{{ formatNumber(report.headline?.requestCount) }}</dd>
</div>
<div>
<dt>接口 P95</dt>
<dd>{{ formatNumber(report.headline?.requestP95Milliseconds, 1) }}<small>ms</small></dd>
</div>
<div>
<dt>5xx 比例</dt>
<dd>{{ formatNumber(report.headline?.serverErrorRatePercent, 2) }}<small>%</small></dd>
</div>
<div>
<dt>数据库 P95</dt>
<dd>{{ formatNumber(report.headline?.databaseP95Milliseconds, 1) }}<small>ms</small></dd>
</div>
<div>
<dt>慢查询</dt>
<dd>{{ formatNumber(report.headline?.slowDatabaseCommandCount) }}</dd>
</div>
<div>
<dt>查询失败</dt>
<dd>{{ formatNumber(report.headline?.failedDatabaseCommandCount) }}</dd>
</div>
</dl>
</div>
<div v-if="hasSamples" class="performance-rail">
<div ref="chartElement" class="rail-chart" aria-label="请求速率与接口 P95 趋势图" />
</div>
<div v-else class="samples-empty">
数据源已连接但该时间范围内尚未收到请求指标
</div>
<div class="rankings">
<article class="ranking-board">
<div class="ranking-heading">
<div>
<span>HTTP ROUTES</span>
<h4>接口耗时排行</h4>
</div>
<small>P95 / 请求量 / 5xx</small>
</div>
<div v-if="report.endpoints.length" class="ranking-list">
<div
v-for="(item, index) in report.endpoints"
:key="item.name"
class="ranking-row"
>
<b>{{ String(index + 1).padStart(2, '0') }}</b>
<div>
<strong :title="item.name">{{ item.name }}</strong>
<span>
<i
:style="{ width: metricWidth(item.p95Milliseconds, report.endpoints) }"
/>
</span>
</div>
<dl>
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
<dt>{{ formatNumber(item.requestCount) }} · {{ formatNumber(item.exceptionalCount) }} 错误</dt>
</dl>
</div>
</div>
<p v-else class="ranking-empty">该范围内没有接口指标</p>
</article>
<article class="ranking-board database-board">
<div class="ranking-heading">
<div>
<span>DATABASE QUERIES</span>
<h4>数据库查询排行</h4>
</div>
<small>P95 / 调用量 / 慢查询</small>
</div>
<div v-if="report.databaseQueries.length" class="ranking-list">
<div
v-for="(item, index) in report.databaseQueries"
:key="item.name"
class="ranking-row"
>
<b>{{ String(index + 1).padStart(2, '0') }}</b>
<div>
<strong :title="item.name">{{ item.name }}</strong>
<span>
<i
:style="{ width: metricWidth(item.p95Milliseconds, report.databaseQueries) }"
/>
</span>
</div>
<dl>
<dd>{{ formatNumber(item.p95Milliseconds, 1) }} ms</dd>
<dt>{{ formatNumber(item.requestCount) }} · {{ formatNumber(item.exceptionalCount) }} </dt>
</dl>
</div>
</div>
<p v-else class="ranking-empty">该范围内没有数据库指标</p>
</article>
</div>
<footer class="performance-foot">
<span>生成于 {{ formatTime(report.generatedAt) }} · 页面数据采用短时缓存</span>
<a
v-if="report.dashboardUrl"
:href="report.dashboardUrl"
target="_blank"
rel="noreferrer"
>
查看原始调用链
<el-icon><TopRight /></el-icon>
</a>
</footer>
</template>
</section>
</template>
<style scoped>
.performance-panel {
--ink: #1e2932;
--muted: #66747e;
--line: #d8dee1;
--paper: #f6f8f8;
--panel: #fff;
--signal: #1f7468;
--warning: #b47722;
--danger: #b1463e;
background: var(--panel);
border: 1px solid var(--line);
color: var(--ink);
min-width: 0;
overflow: hidden;
}
.performance-heading {
align-items: flex-end;
background:
linear-gradient(90deg, rgba(31, 116, 104, 0.06), transparent 38%),
var(--panel);
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
padding: 20px 22px 18px;
}
.performance-heading span,
.ranking-heading span,
.strip-lead > span {
color: var(--signal);
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: .12em;
}
.performance-heading h3 {
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 21px;
margin: 5px 0 3px;
}
.performance-heading p {
color: var(--muted);
font-size: 12px;
margin: 0;
}
.performance-actions {
align-items: center;
display: flex;
gap: 10px;
}
.range-switch {
background: #edf1f1;
display: flex;
padding: 3px;
}
.range-switch button {
background: transparent;
border: 0;
color: #68767e;
cursor: pointer;
font-size: 11px;
padding: 7px 11px;
transition: background .16s ease, color .16s ease;
}
.range-switch button.active {
background: var(--ink);
color: #fff;
}
.range-switch button:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.performance-loading {
padding: 32px;
}
.source-state {
align-items: flex-start;
background: #f8faf9;
display: grid;
gap: 16px;
grid-template-columns: auto minmax(0, 1fr) auto;
margin: 22px;
padding: 24px;
}
.source-state > .el-icon {
background: #e7efed;
color: var(--signal);
font-size: 22px;
padding: 12px;
}
.source-state strong {
display: block;
font-size: 15px;
margin: 2px 0 6px;
}
.source-state p,
.source-state small {
color: var(--muted);
line-height: 1.6;
margin: 0;
}
.source-state p { font-size: 12px; }
.source-state small { font-size: 10px; }
.source-state.is-unavailable > .el-icon { background: #f6e9e8; color: var(--danger); }
.source-state a,
.performance-foot a {
align-items: center;
color: var(--signal);
display: inline-flex;
font-size: 11px;
gap: 4px;
text-decoration: none;
}
.performance-strip {
border-bottom: 1px solid var(--line);
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
}
.strip-lead {
background: var(--ink);
color: #fff;
padding: 18px 20px;
}
.strip-lead > span { color: #84c7bc; }
.strip-lead strong { display: block; font-size: 13px; margin: 9px 0 13px; }
.strip-lead small { color: #aebbc2; font-family: "Cascadia Mono", Consolas, monospace; font-size: 9px; }
.performance-strip > dl {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
margin: 0;
}
.performance-strip dl > div {
border-right: 1px solid #e5eaec;
padding: 16px 14px;
}
.performance-strip dl > div:last-child { border-right: 0; }
.performance-strip dt { color: var(--muted); font-size: 10px; margin-bottom: 9px; }
.performance-strip dd { font-family: "Cascadia Mono", Consolas, monospace; font-size: 18px; margin: 0; }
.performance-strip dd small { color: var(--muted); font-size: 9px; margin-left: 3px; }
.performance-rail {
border-bottom: 1px solid var(--line);
padding: 16px 20px 8px;
}
.rail-chart { height: 260px; width: 100%; }
.samples-empty,
.ranking-empty {
color: var(--muted);
font-size: 12px;
margin: 0;
padding: 44px 22px;
text-align: center;
}
.samples-empty { border-bottom: 1px solid var(--line); }
.rankings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ranking-board {
border-right: 1px solid var(--line);
min-width: 0;
padding: 18px 20px 20px;
}
.ranking-board:last-child { border-right: 0; }
.ranking-heading {
align-items: flex-start;
display: flex;
justify-content: space-between;
margin-bottom: 14px;
}
.ranking-heading h4 {
font-family: "Noto Serif SC", "Source Han Serif SC", serif;
font-size: 15px;
margin: 4px 0 0;
}
.ranking-heading > small {
color: #89959b;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 9px;
margin-top: 5px;
}
.ranking-list { display: grid; gap: 1px; }
.ranking-row {
align-items: center;
background: #f8fafa;
display: grid;
gap: 11px;
grid-template-columns: 26px minmax(0, 1fr) 116px;
min-height: 50px;
padding: 7px 10px;
}
.ranking-row > b {
color: #9aa6ab;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
}
.ranking-row > div { min-width: 0; }
.ranking-row strong {
display: block;
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ranking-row > div > span {
background: #dfe7e7;
display: block;
height: 3px;
margin-top: 8px;
overflow: hidden;
}
.ranking-row i {
background: var(--signal);
display: block;
height: 100%;
}
.database-board .ranking-row i { background: var(--warning); }
.ranking-row dl {
margin: 0;
text-align: right;
}
.ranking-row dd {
font-family: "Cascadia Mono", Consolas, monospace;
font-size: 10px;
margin: 0 0 3px;
}
.ranking-row dt { color: var(--muted); font-size: 9px; }
.performance-foot {
align-items: center;
background: var(--paper);
border-top: 1px solid var(--line);
color: var(--muted);
display: flex;
font-size: 10px;
justify-content: space-between;
padding: 11px 20px;
}
@media (prefers-reduced-motion: reduce) {
.range-switch button { transition: none; }
}
@media (max-width: 1120px) {
.performance-strip { grid-template-columns: 190px minmax(0, 1fr); }
.performance-strip > dl { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.performance-strip dl > div:nth-child(3) { border-right: 0; }
.performance-strip dl > div:nth-child(-n+3) { border-bottom: 1px solid #e5eaec; }
}
@media (max-width: 820px) {
.performance-heading { align-items: flex-start; gap: 16px; }
.performance-actions { align-items: flex-end; flex-direction: column-reverse; }
.performance-strip { display: block; }
.rankings { grid-template-columns: 1fr; }
.ranking-board { border-bottom: 1px solid var(--line); border-right: 0; }
.ranking-board:last-child { border-bottom: 0; }
}
@media (max-width: 560px) {
.performance-heading { display: block; padding: 17px 14px; }
.performance-actions { align-items: stretch; flex-direction: row; margin-top: 14px; }
.range-switch { flex: 1; overflow-x: auto; }
.range-switch button { flex: 1 0 auto; padding-inline: 9px; }
.source-state { grid-template-columns: auto 1fr; margin: 12px; padding: 16px; }
.source-state a { grid-column: 2; }
.performance-strip > dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.performance-strip dl > div,
.performance-strip dl > div:nth-child(3) { border-bottom: 1px solid #e5eaec; border-right: 1px solid #e5eaec; }
.performance-strip dl > div:nth-child(even) { border-right: 0; }
.performance-strip dl > div:nth-last-child(-n+2) { border-bottom: 0; }
.performance-rail { padding-inline: 8px; }
.rail-chart { height: 230px; }
.ranking-board { padding-inline: 12px; }
.ranking-heading > small { display: none; }
.ranking-row { grid-template-columns: 22px minmax(0, 1fr) 96px; padding-inline: 7px; }
.performance-foot { align-items: flex-start; gap: 8px; }
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import http from '../api/http'
interface SystemVersionResponse {
version: string
}
const backendVersion = ref('获取中')
const currentYear = new Date().getFullYear()
const frontendVersion = __APP_VERSION__
onMounted(async () => {
try {
const { data } = await http.get<SystemVersionResponse>('/system/version')
backendVersion.value = data.version ? `v${data.version}` : '未知'
} catch {
backendVersion.value = '未知'
}
})
</script>
<template>
<footer class="site-footer" aria-label="网站版权与版本信息">
<div class="site-footer-inner">
<p>© {{ currentYear }} 明序教务 · 版权所有</p>
<div class="version-list" aria-label="系统版本">
<span>前端版本 <b>v{{ frontendVersion }}</b></span>
<span>后端版本 <b>{{ backendVersion }}</b></span>
</div>
</div>
</footer>
</template>
<style scoped>
.site-footer {
position: relative;
z-index: 1;
border-top: 1px solid rgba(255, 255, 255, .1);
color: #d9e0f2;
background:
linear-gradient(90deg, rgba(255, 255, 255, .025) 1px, transparent 1px),
linear-gradient(112deg, #152550 0%, #1d326c 72%, #176a70 130%);
background-size: 28px 28px, auto;
}
.site-footer-inner {
min-height: 58px;
max-width: 1540px;
margin: 0 auto;
padding: 11px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.site-footer p {
margin: 0;
font-size: 12px;
letter-spacing: .04em;
}
.version-list {
display: flex;
align-items: center;
gap: 8px;
}
.version-list span {
padding: 5px 9px;
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 4px;
color: #aeb9d8;
background: rgba(255, 255, 255, .055);
font-size: 10px;
white-space: nowrap;
}
.version-list b {
margin-left: 5px;
color: #66d5c5;
font: 700 10px/1.2 Consolas, monospace;
}
@media (max-width: 600px) {
.site-footer-inner {
min-height: 72px;
padding: 10px 14px;
align-items: flex-start;
flex-direction: column;
justify-content: center;
gap: 7px;
}
.version-list {
width: 100%;
}
.version-list span {
flex: 1;
text-align: center;
}
}
</style>
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+2
View File
@@ -502,6 +502,8 @@ button { cursor: pointer; }
.cell-add { margin: auto; color: transparent; font-size: 9px; }
.timetable-cell.editable:hover .cell-add { color: #a0a8b4; }
.schedule-card { min-width: 0; padding: 8px 9px; display: grid; gap: 4px; position: relative; border-left: 3px solid #45bcae; color: #eaf2ff; background: linear-gradient(130deg, #263f80, #1a2e64); box-shadow: 0 4px 10px rgba(24,40,83,.12); cursor: pointer; }
.schedule-card.experiment { border-left-color: #f2b84b; background: linear-gradient(130deg, #705022, #4d3518); }
.schedule-card.experiment span { color: #ffd88a; }
.schedule-card.readonly { cursor: default; }
.schedule-card span { padding-right: 16px; color: #74d5c8; font: 700 8px/1.2 Consolas, monospace; letter-spacing: .04em; }
.schedule-card b { overflow: hidden; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
+216 -14
View File
@@ -27,7 +27,7 @@ const statusFilter = ref('')
const projectDialog = ref(false)
const editingProjectId = ref('')
const projectForm = reactive({
teachingTaskId: '',
teachingTaskIds: [] as string[],
code: '',
name: '',
arrangementMode: 'Centralized',
@@ -47,13 +47,20 @@ const sessionForm = reactive({
notes: '',
})
const batchSessionDialog = ref(false)
const batchSessionProjectIds = ref<string[]>([])
const batchSessionRows = ref<any[]>([])
const participantsDialog = ref(false)
const participantSession = ref<any>(null)
const participants = ref<any[]>([])
const participantsLoading = ref(false)
const selectedTask = computed(() =>
options.tasks.find((task) => task.id === projectForm.teachingTaskId),
options.tasks.find((task) => task.id === projectForm.teachingTaskIds[0]),
)
const batchProjectOptions = computed(() =>
projects.value.filter((project) => project.status !== 'Closed'),
)
const activePeriods = computed(() =>
options.periods.filter((period) =>
@@ -82,7 +89,7 @@ const statusLabels: Record<string, string> = {
function resetProjectForm() {
editingProjectId.value = ''
Object.assign(projectForm, {
teachingTaskId: '',
teachingTaskIds: [],
code: '',
name: '',
arrangementMode: 'Centralized',
@@ -106,7 +113,7 @@ function openCreateProject() {
function openEditProject(project: any) {
editingProjectId.value = project.id
Object.assign(projectForm, {
teachingTaskId: project.teachingTaskId,
teachingTaskIds: [project.teachingTaskId],
code: project.code,
name: project.name,
arrangementMode: project.arrangementMode,
@@ -118,13 +125,13 @@ function openEditProject(project: any) {
}
async function saveProject() {
if (!projectForm.teachingTaskId || !projectForm.code.trim()
if (!projectForm.teachingTaskIds.length || !projectForm.code.trim()
|| !projectForm.name.trim() || projectForm.dates.length !== 2) {
ElMessage.warning('请填写教学任务、项目编码、名称和开放日期')
return
}
const payload = {
teachingTaskId: projectForm.teachingTaskId,
teachingTaskId: projectForm.teachingTaskIds[0],
code: projectForm.code,
name: projectForm.name,
arrangementMode: projectForm.arrangementMode,
@@ -138,8 +145,12 @@ async function saveProject() {
await http.put(`/experiments/${editingProjectId.value}`, payload)
ElMessage.success('实验项目已更新')
} else {
await http.post('/experiments', payload)
ElMessage.success('实验项目已创建')
await http.post('/experiments/batch', {
...payload,
teachingTaskId: undefined,
teachingTaskIds: projectForm.teachingTaskIds,
})
ElMessage.success(`已为 ${projectForm.teachingTaskIds.length} 个教学任务创建实验项目`)
}
projectDialog.value = false
await load()
@@ -148,6 +159,78 @@ async function saveProject() {
}
}
function openBatchSession() {
batchSessionProjectIds.value = []
batchSessionRows.value = []
batchSessionDialog.value = true
}
function syncBatchSessionRows() {
const existing = new Map(batchSessionRows.value.map((row) => [row.projectId, row]))
batchSessionRows.value = batchSessionProjectIds.value.map((projectId) => {
const current = existing.get(projectId)
if (current) return current
const project = projects.value.find((item) => item.id === projectId)
const firstPeriod = options.periods.find((item) =>
item.academicTermId === project?.academicTermId,
)
return {
projectId,
classroomId: '',
sessionDate: project?.startDate ?? '',
startPeriod: firstPeriod?.periodNumber,
periodCount: 2,
capacity: 30,
notes: '',
}
})
}
function applyFirstBatchTime() {
const first = batchSessionRows.value[0]
if (!first) return
batchSessionRows.value.slice(1).forEach((row) => {
row.sessionDate = first.sessionDate
row.startPeriod = first.startPeriod
row.periodCount = first.periodCount
})
ElMessage.success('已套用首行的日期和节次,请分别选择不冲突的实验室')
}
function batchProject(projectId: string) {
return projects.value.find((project) => project.id === projectId)
}
function periodsForProject(projectId: string) {
const project = batchProject(projectId)
return options.periods.filter((period) =>
period.academicTermId === project?.academicTermId,
)
}
async function saveBatchSessions() {
if (!batchSessionRows.value.length) {
ElMessage.warning('请至少选择一个实验项目')
return
}
if (batchSessionRows.value.some((row) =>
!row.classroomId || !row.sessionDate || !row.startPeriod || !row.periodCount,
)) {
ElMessage.warning('请完整填写每个项目的日期、节次、实验室和容量')
return
}
try {
await http.post('/experiments/sessions/batch', {
items: batchSessionRows.value,
})
batchSessionDialog.value = false
ElMessage.success(`已批量安排 ${batchSessionRows.value.length} 个实验场次`)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function openSession(project: any) {
selectedProject.value = project
const firstPeriod = options.periods.find((item) =>
@@ -382,8 +465,11 @@ onMounted(async () => {
<p v-else>把实验项目分成两条运行轨道集中排入固定课次或开放场次供学生自主预约</p>
</div>
<div class="intro-actions">
<el-button v-if="!isStudent" :icon="Calendar" @click="openBatchSession">
批量排课
</el-button>
<el-button v-if="!isStudent" type="primary" :icon="Plus" @click="openCreateProject">
新建实验项目
批量设置实验任务
</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
@@ -627,19 +713,22 @@ onMounted(async () => {
<el-dialog
v-model="projectDialog"
:title="editingProjectId ? '编辑实验项目' : '新建实验项目'"
:title="editingProjectId ? '编辑实验项目' : '批量设置实验任务'"
width="720px"
top="5vh"
>
<el-form label-position="top" class="experiment-form">
<div class="form-section">
<header><span>PROJECT</span><b>规定实验项目</b></header>
<el-form-item label="所属教学任务" required>
<el-form-item :label="editingProjectId ? '所属教学任务' : '适用教学任务(可多选)'" required>
<el-select
v-model="projectForm.teachingTaskId"
v-model="projectForm.teachingTaskIds"
filterable
multiple
collapse-tags
collapse-tags-tooltip
:disabled="!!editingProjectId"
placeholder="选择已发布教学任务"
placeholder="选择同一学期、同一课程的已发布教学任务"
@change="onTaskChange"
>
<el-option
@@ -647,8 +736,14 @@ onMounted(async () => {
:key="task.id"
:label="`${task.courseCode} · ${task.courseName} · ${task.taskNumber}`"
:value="task.id"
:disabled="!!selectedTask
&& (task.academicTermId !== selectedTask.academicTermId
|| task.courseId !== selectedTask.courseId)"
/>
</el-select>
<small v-if="!editingProjectId" class="form-help">
已选 {{ projectForm.teachingTaskIds.length }} 实验编码名称内容和开放日期将一次应用到这些教学任务
</small>
</el-form-item>
<div class="form-grid two">
<el-form-item label="项目编码" required>
@@ -708,7 +803,13 @@ onMounted(async () => {
</el-form>
<template #footer>
<el-button @click="projectDialog = false">取消</el-button>
<el-button type="primary" @click="saveProject">保存项目</el-button>
<el-button type="primary" @click="saveProject">
{{ editingProjectId
? '保存项目'
: projectForm.teachingTaskIds.length
? `创建 ${projectForm.teachingTaskIds.length} 个项目`
: '创建项目' }}
</el-button>
</template>
</el-dialog>
@@ -778,6 +879,86 @@ onMounted(async () => {
</template>
</el-dialog>
<el-dialog
v-model="batchSessionDialog"
title="批量安排实验场次"
width="min(1180px, 94vw)"
top="4vh"
>
<el-alert
title="一次提交整批排课;系统会逐条检查管理范围、开放日期、实验室、课表、教师和班级冲突,任何一条失败都不会写入本批次。"
type="info"
:closable="false"
show-icon
/>
<el-form label-position="top" class="batch-session-form">
<el-form-item label="选择实验项目" required>
<el-select
v-model="batchSessionProjectIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择需要一起排课的实验项目"
@change="syncBatchSessionRows"
>
<el-option
v-for="project in batchProjectOptions"
:key="project.id"
:label="`${project.courseCode} · ${project.name} · ${project.taskNumber}`"
:value="project.id"
/>
</el-select>
</el-form-item>
<div v-if="batchSessionRows.length" class="batch-session-tools">
<span> {{ batchSessionRows.length }} 条排课</span>
<el-button size="small" @click="applyFirstBatchTime">套用首行日期与节次</el-button>
</div>
<div class="batch-session-table">
<article v-for="(row, index) in batchSessionRows" :key="row.projectId" class="batch-session-row">
<div class="batch-project-cell">
<span>{{ index + 1 }}</span>
<div>
<b>{{ batchProject(row.projectId)?.name }}</b>
<small>{{ batchProject(row.projectId)?.taskNumber }} · {{ batchProject(row.projectId)?.classNames.join('、') || '选课学生' }}</small>
</div>
</div>
<el-date-picker
v-model="row.sessionDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="实验日期"
/>
<el-select v-model="row.startPeriod" placeholder="起始节次">
<el-option
v-for="period in periodsForProject(row.projectId)"
:key="period.periodNumber"
:label="period.name"
:value="period.periodNumber"
/>
</el-select>
<el-input-number v-model="row.periodCount" :min="1" :max="12" controls-position="right" />
<el-select v-model="row.classroomId" filterable placeholder="实验室">
<el-option
v-for="room in options.classrooms"
:key="room.id"
:label="`${room.campusName} · ${room.buildingName} ${room.name} · ${room.capacity} 人`"
:value="room.id"
/>
</el-select>
<el-input-number v-model="row.capacity" :min="1" :max="10000" controls-position="right" />
</article>
</div>
<el-empty v-if="!batchSessionRows.length" :image-size="60" description="选择实验项目后,在同一张表中完成排课" />
</el-form>
<template #footer>
<el-button @click="batchSessionDialog = false">取消</el-button>
<el-button type="primary" :disabled="!batchSessionRows.length" @click="saveBatchSessions">
提交整批排课
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="participantsDialog"
:title="`${participantSession?.project?.arrangementMode === 'Centralized' ? '应到名单' : '预约名单'} · ${participantSession ? formatSessionTime(participantSession) : ''}`"
@@ -912,6 +1093,7 @@ onMounted(async () => {
.student-booking-summary span { display: grid; gap: 2px; color: #365f5b; font-size: 12px; }
.student-booking-summary b { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
.experiment-form { display: grid; gap: 13px; }
.form-help { display: block; margin-top: 7px; color: var(--muted); line-height: 1.5; }
.form-section { padding: 14px 15px 1px; border: 1px solid var(--line); background: #fbfcfd; }
.form-section > header { display: flex; align-items: baseline; gap: 9px; margin-bottom: 13px; }
.form-section > header span { color: var(--lab-teal); font: 700 10px/1 Consolas, monospace; letter-spacing: .08em; }
@@ -920,6 +1102,25 @@ onMounted(async () => {
.mode-choice :deep(.el-radio-button__inner) { display: grid; gap: 5px; width: 100%; padding: 13px; }
.mode-choice b { font-size: 13px; }
.mode-choice small { font-size: 10px; font-weight: 400; }
.batch-session-form { display: grid; gap: 12px; margin-top: 16px; }
.batch-session-tools { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 12px; }
.batch-session-table { display: grid; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.batch-session-row {
display: grid;
grid-template-columns: minmax(230px, 1.4fr) 150px 120px 110px minmax(230px, 1.3fr) 110px;
gap: 8px;
align-items: center;
min-width: 1000px;
padding: 10px;
border: 1px solid #dce5e9;
background: #f9fbfc;
}
.batch-project-cell { display: flex; align-items: center; gap: 10px; min-width: 0; }
.batch-project-cell > span { display: grid; width: 25px; height: 25px; place-items: center; border-radius: 50%; background: #e5f0f5; color: var(--lab-blue); font: 700 11px/1 Consolas, monospace; }
.batch-project-cell > div { display: grid; gap: 3px; min-width: 0; }
.batch-project-cell b, .batch-project-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.batch-project-cell b { color: var(--lab-ink); font-size: 12px; }
.batch-project-cell small { color: var(--muted); font-size: 10px; }
@media (prefers-reduced-motion: reduce) {
.session-ticket { transition: none; }
}
@@ -936,5 +1137,6 @@ onMounted(async () => {
.ticket-action { grid-column: 1 / -1; justify-content: flex-start; padding: 0 10px 10px; }
.project-actions, .student-booking-summary { padding-inline: 14px; }
.mode-choice { grid-template-columns: 1fr; }
.intro-actions { flex-wrap: wrap; justify-content: flex-end; }
}
</style>
+3
View File
@@ -8,6 +8,7 @@ import {
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import AppUpdateManagementPanel from '../components/AppUpdateManagementPanel.vue'
import PerformanceReportPanel from '../components/PerformanceReportPanel.vue'
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
@@ -406,6 +407,8 @@ onMounted(refreshAll)
</div>
</section>
<PerformanceReportPanel />
<section class="console-split">
<article class="alerts-panel">
<div class="panel-heading">
+48 -16
View File
@@ -125,6 +125,19 @@ const publishStatusText = computed(() => {
const selectedTaskConstraint = computed(() =>
constraints.value.find((item) => item.id === entryForm.teachingTaskId),
)
const isExperimentRoom = (room: any) =>
['实验', '实训', '机房', '语音'].some((keyword) => room.roomType?.includes(keyword))
const entryClassrooms = computed(() =>
classrooms.value.filter((room) =>
(!selectedTaskConstraint.value?.requiredCampusId
|| room.campusId === selectedTaskConstraint.value.requiredCampusId) &&
(!selectedTaskConstraint.value?.requiredBuildingId
|| room.buildingId === selectedTaskConstraint.value.requiredBuildingId) &&
(!selectedTaskConstraint.value?.allowedClassroomIds?.length
|| selectedTaskConstraint.value.allowedClassroomIds.includes(room.id)) &&
(entryForm.kind !== 'Experiment' || isExperimentRoom(room)),
),
)
const entryWeekdays = computed(() => {
const allowedDays = selectedTaskConstraint.value?.allowedDayOfWeeks ?? []
return allowedDays.length
@@ -416,7 +429,7 @@ function openManualHandling() {
async function autoSchedule() {
try {
await ElMessageBox.confirm(
'系统会保留当前手工安排,并为尚未排满的教学任务分配教师可用时间和符合约束的教室。生成后仍可手工调整。',
'系统会保留当前手工安排,同时补齐理论课和实验课。实验课仅使用实验室、实训室、机房等场地;生成后仍可手工调整。',
'开始自动排课',
{ type: 'warning', confirmButtonText: '生成排课', cancelButtonText: '取消' },
)
@@ -657,6 +670,7 @@ function openEntry(entry?: any, day?: number, period?: number) {
editingEntryId.value = entry?.id ?? ''
Object.assign(entryForm, {
teachingTaskId: entry?.teachingTaskId,
kind: entry?.kind ?? 'Lecture',
classroomId: entry?.classroomId,
dayOfWeek: entry?.dayOfWeek ?? day ?? 1,
startPeriod: entry?.startPeriod ?? period ?? 1,
@@ -679,7 +693,7 @@ function changeEntryTask() {
!task.allowedDayOfWeeks.includes(entryForm.dayOfWeek)) {
entryForm.dayOfWeek = task.allowedDayOfWeeks[0]
}
if (task.requiresClassroom === false) {
if (task.requiresClassroom === false && entryForm.kind !== 'Experiment') {
entryForm.classroomId = null
return
}
@@ -687,15 +701,25 @@ function changeEntryTask() {
if (room && (
(task.requiredCampusId && room.campusId !== task.requiredCampusId) ||
(task.requiredBuildingId && room.buildingId !== task.requiredBuildingId) ||
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id))
(task.allowedClassroomIds?.length && !task.allowedClassroomIds.includes(room.id)) ||
(entryForm.kind === 'Experiment' && !isExperimentRoom(room))
)) {
entryForm.classroomId = null
}
}
function changeEntryKind() {
const room = classrooms.value.find((item) => item.id === entryForm.classroomId)
if (entryForm.kind === 'Experiment' && room && !isExperimentRoom(room)) {
entryForm.classroomId = null
}
}
async function saveEntry() {
if (!entryForm.teachingTaskId ||
(selectedTaskConstraint.value?.requiresClassroom !== false && !entryForm.classroomId)) {
((entryForm.kind === 'Experiment' ||
selectedTaskConstraint.value?.requiresClassroom !== false) &&
!entryForm.classroomId)) {
ElMessage.warning('请选择教学任务,并按课程要求选择教室。')
return
}
@@ -714,7 +738,8 @@ async function saveEntry() {
ElMessage.warning('所选星期不在该教学任务允许的上课日内。')
return
}
if (selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
if (entryForm.kind !== 'Experiment' &&
selectedTaskConstraint.value?.requiresClassroom === false) entryForm.classroomId = null
try {
const base = `/schedules/plans/${selected.value.id}/entries`
if (editingEntryId.value) await http.put(`${base}/${editingEntryId.value}`, entryForm)
@@ -928,10 +953,13 @@ onBeforeUnmount(() => {
v-for="entry in entriesAt(day.value, period)"
:key="entry.id"
class="schedule-card"
:class="{ readonly: !isDraft }"
:class="{ readonly: !isDraft, experiment: entry.kind === 'Experiment' }"
@click="isDraft && openEntry(entry)"
>
<span>{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}</span>
<span>
{{ entry.kind === 'Experiment' ? '实验课' : '理论课' }} ·
{{ entry.courseCode }} · {{ patternLabels[entry.weekPattern] }}
</span>
<b>{{ entry.courseName }}</b>
<small>{{ entry.teacherNames.join('、') }} · {{ entry.classroomName || '不占用教室' }}</small>
<i>{{ entry.startWeek }}{{ entry.endWeek }} / 连上 {{ entry.periodCount }} </i>
@@ -986,15 +1014,23 @@ onBeforeUnmount(() => {
<el-option v-for="item in tasks" :key="item.id" :label="`${item.taskNumber} · ${item.name}`" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="课次类型" required>
<el-radio-group v-model="entryForm.kind" @change="changeEntryKind">
<el-radio-button value="Lecture">理论课</el-radio-button>
<el-radio-button value="Experiment" :disabled="!selectedTaskConstraint?.coursePracticeHours">
实验课
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="selectedTaskConstraint"
:title="`普通课表每周 ${selectedTaskConstraint.weeklyHours} 学时(实践 ${selectedTaskConstraint.coursePracticeHours} 学时另由实验管理安排);可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
:title="`课程共 ${selectedTaskConstraint.courseTotalHours} 学时:理论 ${selectedTaskConstraint.courseTotalHours - selectedTaskConstraint.coursePracticeHours} 学时、实验 ${selectedTaskConstraint.coursePracticeHours} 学时,均须在本课表排足;可排第 ${selectedTaskConstraint.startWeek}—${selectedTaskConstraint.endWeek} 周;允许上课日:${entryWeekdays.map((day) => day.label).join('、')}`"
type="info"
:closable="false"
show-icon
/>
<el-alert
v-if="selectedTaskConstraint?.requiresClassroom === false"
v-if="selectedTaskConstraint?.requiresClassroom === false && entryForm.kind !== 'Experiment'"
title="该课程不占用教室,仍会校验教师和行政班时间冲突。"
type="info"
:closable="false"
@@ -1002,19 +1038,15 @@ onBeforeUnmount(() => {
/>
<el-form-item
v-else
label="教室"
:label="entryForm.kind === 'Experiment' ? '实验室 / 实训室 / 机房' : '教室'"
required
:hint="selectedTaskConstraint?.requiredBuildingId ? '仅显示约束范围内教室' : ''"
>
<el-select v-model="entryForm.classroomId" filterable>
<el-option
v-for="item in classrooms.filter((room) =>
(!selectedTaskConstraint?.requiredCampusId || room.campusId === selectedTaskConstraint.requiredCampusId) &&
(!selectedTaskConstraint?.requiredBuildingId || room.buildingId === selectedTaskConstraint.requiredBuildingId) &&
(!selectedTaskConstraint?.allowedClassroomIds?.length || selectedTaskConstraint.allowedClassroomIds.includes(room.id))
)"
v-for="item in entryClassrooms"
:key="item.id"
:label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.capacity}人)`"
:label="`${item.campusName} / ${item.buildingName} / ${item.name}${item.roomType}${item.capacity}人)`"
:value="item.id"
/>
</el-select>
+7 -9
View File
@@ -79,12 +79,10 @@ const selectedCourse = computed(() =>
)
const regularScheduleHours = (course: any) =>
Math.max(0, Number(course?.totalHours ?? 0) - Number(course?.practiceHours ?? 0))
const targetCourseHours = (course: any, schedulingMode: string) =>
schedulingMode === 'Flexible'
? Number(course?.totalHours ?? 0)
: regularScheduleHours(course)
const targetCourseHours = (course: any) =>
Number(course?.totalHours ?? 0)
const selectedCourseTargetHours = computed(() =>
targetCourseHours(selectedCourse.value, form.schedulingMode),
targetCourseHours(selectedCourse.value),
)
const plannedHours = computed(() =>
form.startWeek && form.endWeek && form.weeklyHours && form.endWeek >= form.startWeek
@@ -124,7 +122,7 @@ const generationPlannedHours = computed(() =>
)
const generationHoursMatch = computed(() =>
!selectedGenerationCourse.value ||
generationPlannedHours.value === regularScheduleHours(selectedGenerationCourse.value),
generationPlannedHours.value === Number(selectedGenerationCourse.value?.totalHours ?? 0),
)
const manageableCourses = computed(() => {
if (isSuperAdmin.value) return courses.value
@@ -364,7 +362,7 @@ async function save() {
}
if (!hoursMatch.value) {
ElMessage.warning(
`该课程普通课表应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时;实践学时请在实验管理中安排`,
`该课程理论课和实验课共应安排 ${selectedCourseTargetHours.value} 学时,当前安排合计 ${plannedHours.value} 学时。`,
)
return
}
@@ -551,7 +549,7 @@ async function generatePublicTasks() {
}
if (!generationHoursMatch.value) {
ElMessage.warning(
`该课程普通课表应安排 ${regularScheduleHours(selectedGenerationCourse.value)} 学时,当前安排合计 ${generationPlannedHours.value} 学时;实践学时不进入普通课表`,
`该课程理论课和实验课共应安排 ${selectedGenerationCourse.value?.totalHours ?? 0} 学时,当前安排合计 ${generationPlannedHours.value} 学时。`,
)
return
}
@@ -773,7 +771,7 @@ onMounted(async () => {
<el-radio-button value="Flexible">非排时课程</el-radio-button>
</el-radio-group>
<small class="field-hint">
正常排课只安排非实践学时全部由实验模块安排的课程选择非排时课程
正常排课会同时安排理论课和实验课只有无需固定星期节次和场地的课程选择非排时课程
</small>
</el-form-item>
<el-form-item label="授课教师">
+12 -1
View File
@@ -1014,12 +1014,14 @@ onMounted(async () => {
:class="{
'exam-block': entry.isExam,
'experiment-block': entry.isExperiment,
'scheduled-experiment-block': !entry.isExperiment && entry.kind === 'Experiment',
}"
:style="gridEntryStyle(entry)"
>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
<span v-if="!entry.isExperiment && entry.kind === 'Experiment'" class="entry-kind scheduled-experiment-kind">实验课</span>
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
@@ -1064,12 +1066,14 @@ onMounted(async () => {
:class="{
'exam-block': entry.isExam,
'experiment-block': entry.isExperiment,
'scheduled-experiment-block': !entry.isExperiment && entry.kind === 'Experiment',
}"
:style="dayEntryStyle(entry)"
>
<strong>
<span v-if="entry.isExam" class="entry-kind">考试</span>
<span v-if="entry.isExperiment" class="entry-kind experiment-kind">实验</span>
<span v-if="!entry.isExperiment && entry.kind === 'Experiment'" class="entry-kind scheduled-experiment-kind">实验课</span>
{{ entry.isExperiment ? entry.experimentProjectName : entry.courseName }}
</strong>
<span v-if="entry.isExam && entry.examPlanName">{{ entry.examPlanName }}</span>
@@ -1113,7 +1117,11 @@ onMounted(async () => {
{{ entry.startWeek }} ·
{{ entry.startPeriod }}{{ entry.startPeriod + entry.periodCount - 1 }}
</small>
<small>{{ entry.teacherNames.join('、') || '监考教师待定' }}</small>
<small v-if="entry.examRoomCount > 1">
任课教师{{ entry.teacherNames.join('、') || '待定' }} ·
学生登录后可查看本人考场及监考教师
</small>
<small v-else>{{ entry.teacherNames.join('') || '监考教师待定' }}</small>
</div>
</article>
</div>
@@ -1329,6 +1337,9 @@ onMounted(async () => {
.course-block.experiment-block small, .day-course-block.experiment-block small { color: #527a74; }
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
.entry-kind.experiment-kind { background: #168276; }
.course-block.scheduled-experiment-block, .day-course-block.scheduled-experiment-block { border-left-color: #b77819; background: #fff6df; color: #73521f; }
.course-block.scheduled-experiment-block strong, .day-course-block.scheduled-experiment-block strong { color: #66430e; }
.entry-kind.scheduled-experiment-kind { background: #b77819; }
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
+8
View File
@@ -1,15 +1,23 @@
import { defineConfig, loadEnv } from 'vite'
import { readFileSync } from 'node:fs'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
const packageJson = JSON.parse(
readFileSync(new URL('./package.json', import.meta.url), 'utf8'),
) as { version: string }
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const isCapacitor = env.VITE_ROUTER_MODE === 'hash'
return {
define: {
__APP_VERSION__: JSON.stringify(packageJson.version),
},
plugins: [
vue(),
AutoImport({