Compare commits
6
Commits
v2.1.0
..
8cb9b4f57b
@@ -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
|
||||
|
||||
@@ -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(
|
||||
var targetHours = TeachingTaskHours.TargetHours(
|
||||
task.Course!,
|
||||
task.StartWeek,
|
||||
task.EndWeek,
|
||||
out var requiredWeeklyHours))
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+5991
File diff suppressed because it is too large
Load Diff
+29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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[]
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
ScheduleEntryKind.Lecture,
|
||||
ScheduleEntryKind.Experiment
|
||||
})
|
||||
{
|
||||
await reportProgress(
|
||||
new(tasks.Count, processedTasks, created, completedTasks),
|
||||
cancellationToken);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetHours = TeachingTaskHours.TargetHours(task.Course!, kind);
|
||||
var scheduledHours = entries
|
||||
.Where(x => x.TeachingTaskId == task.Id)
|
||||
.Sum(x => x.PeriodCount);
|
||||
if (scheduledHours > requiredWeeklyHours)
|
||||
.Where(x =>
|
||||
x.TeachingTaskId == task.Id &&
|
||||
x.Kind == kind)
|
||||
.Sum(TeachingTaskHours.ScheduledHours);
|
||||
var label = kind == ScheduleEntryKind.Experiment ? "实验课" : "理论课";
|
||||
if (scheduledHours > targetHours)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
|
||||
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
|
||||
processedTasks++;
|
||||
if (reportProgress is not null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
$"{task.TaskNumber} · {task.Name} 的{label}已安排 {scheduledHours} 学时," +
|
||||
$"超过课程规定的 {targetHours} 学时,请先删除多余课次。");
|
||||
taskCompleted = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
var remainingHours = targetHours - scheduledHours;
|
||||
while (remainingHours > 0)
|
||||
{
|
||||
var desiredBlock = remainingHours >= 2 ? 2 : 1;
|
||||
var candidate = FindBestCandidate(
|
||||
var candidate = FindBestCandidateForHours(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
desiredBlock,
|
||||
kind,
|
||||
remainingHours,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
if (candidate is null && desiredBlock > 1)
|
||||
{
|
||||
candidate = FindBestCandidate(
|
||||
plan.Id,
|
||||
task,
|
||||
constraint,
|
||||
1,
|
||||
activePeriods,
|
||||
classrooms,
|
||||
entries,
|
||||
cancellationToken);
|
||||
}
|
||||
if (candidate is null) break;
|
||||
|
||||
db.ScheduleEntries.Add(candidate);
|
||||
entries.Add(candidate);
|
||||
created++;
|
||||
remainingHours -= candidate.PeriodCount;
|
||||
remainingHours -= TeachingTaskHours.ScheduledHours(candidate);
|
||||
}
|
||||
|
||||
if (remainingHours == 0)
|
||||
{
|
||||
completedTasks++;
|
||||
}
|
||||
else
|
||||
if (remainingHours > 0)
|
||||
{
|
||||
messages.Add(
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 学时无法安排,请检查教师/班级冲突或场地与时间约束。");
|
||||
$"{task.TaskNumber} · {task.Name} 仍有 {remainingHours} 个{label}学时无法安排," +
|
||||
(kind == ScheduleEntryKind.Experiment
|
||||
? "请检查实验室/机房容量、教师班级冲突或时间约束。"
|
||||
: "请检查教师/班级冲突或场地与时间约束。"));
|
||||
taskCompleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
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,11 +203,15 @@ 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)>();
|
||||
for (var startWeek = task.StartWeek;
|
||||
startWeek + occurrenceCount - 1 <= task.EndWeek;
|
||||
startWeek++)
|
||||
{
|
||||
foreach (var day in allowedDays)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -212,7 +221,8 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
if (Enumerable.Range(start, periodCount).Any(period => !activePeriods.Contains(period)))
|
||||
continue;
|
||||
|
||||
var roomOptions = constraint?.RequiresClassroom == false
|
||||
var roomOptions = kind != ScheduleEntryKind.Experiment &&
|
||||
constraint?.RequiresClassroom == false
|
||||
? new Classroom?[] { null }
|
||||
: rooms.Cast<Classroom?>().ToArray();
|
||||
foreach (var room in roomOptions)
|
||||
@@ -222,14 +232,17 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
SchedulePlanId = planId,
|
||||
TeachingTaskId = task.Id,
|
||||
TeachingTask = task,
|
||||
Kind = kind,
|
||||
ClassroomId = room?.Id,
|
||||
DayOfWeek = day,
|
||||
StartPeriod = start,
|
||||
PeriodCount = periodCount,
|
||||
StartWeek = task.StartWeek,
|
||||
EndWeek = task.EndWeek,
|
||||
StartWeek = startWeek,
|
||||
EndWeek = startWeek + occurrenceCount - 1,
|
||||
WeekPattern = WeekPattern.All,
|
||||
Notes = "自动排课"
|
||||
Notes = kind == ScheduleEntryKind.Experiment
|
||||
? "自动排课 · 实验课"
|
||||
: "自动排课 · 理论课"
|
||||
};
|
||||
if (entries.Any(existing =>
|
||||
ScheduleConflictDetector.TimeOverlaps(existing, proposed) &&
|
||||
@@ -240,11 +253,13 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
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;
|
||||
var score = sameTaskDay * 1000 + dayLoad * 10 + start +
|
||||
roomWaste / 10 + startWeek;
|
||||
candidates.Add((proposed, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.OrderBy(x => x.Score)
|
||||
.ThenBy(x => x.Entry.DayOfWeek)
|
||||
@@ -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)
|
||||
{
|
||||
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(
|
||||
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
|
||||
$"{incomplete.Hours} 个普通排课学时,不能发布。");
|
||||
$"{task.TaskNumber} · {task.Name} 的{target.Label}应安排 " +
|
||||
$"{target.Hours} 学时,当前已安排 {actual} 学时,不能发布。");
|
||||
}
|
||||
|
||||
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} 学时;" +
|
||||
"请删除已包含的实践学时后再发布。");
|
||||
}
|
||||
|
||||
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()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var room in allMixedRooms)
|
||||
{
|
||||
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
|
||||
.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(),
|
||||
session.TeachingTask.Classes
|
||||
link.ExamSession.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
|
||||
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,
|
||||
room.UpdatedAt), slotLookup));
|
||||
link.ExamRoom.UpdatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (resourceType == TimetableResourceType.Class && !studentId.HasValue)
|
||||
{
|
||||
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} 周";
|
||||
|
||||
@@ -31,6 +31,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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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,6 +53,7 @@ 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']
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="授课教师">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user