Compare commits
@@ -6,6 +6,18 @@ Database__Provider=MySql
|
||||
Database__ApplyMigrationsOnStartup=false
|
||||
Database__CommandTimeoutSeconds=30
|
||||
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
|
||||
# Redis 是可选加速器;留空时应用仅使用进程内缓存。
|
||||
# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false"
|
||||
|
||||
Cache__Enabled=true
|
||||
Cache__KeyPrefix=jiaowu:v1
|
||||
Cache__ReferenceExpirationMinutes=30
|
||||
Cache__ReferenceLocalExpirationSeconds=120
|
||||
Cache__TimetableExpirationMinutes=10
|
||||
Cache__TimetableLocalExpirationSeconds=30
|
||||
Cache__AnalyticsExpirationMinutes=3
|
||||
Cache__AnalyticsLocalExpirationSeconds=30
|
||||
Cache__MaximumPayloadKilobytes=2048
|
||||
|
||||
Jwt__Issuer=Jiaowu.Api
|
||||
Jwt__Audience=Jiaowu.Web
|
||||
|
||||
@@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
certs/
|
||||
publish/
|
||||
@@ -219,6 +219,35 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开
|
||||
|
||||
- `/health/live`:只检查进程存活。
|
||||
- `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。
|
||||
- `/health/cache`:检查可选 Redis;未配置 Redis 时返回 `disabled`,Redis
|
||||
故障不会影响数据库就绪探针。
|
||||
|
||||
### 查询缓存与 Redis
|
||||
|
||||
应用使用 HybridCache 统一管理进程内一级缓存和可选 Redis 二级缓存。目前缓存范围为
|
||||
学生激活/基础数据选项、匿名可访问的已发布课表、仪表盘以及统计分析摘要。统计缓存键
|
||||
包含有效数据范围、学院和规范化筛选条件,避免跨学院复用;统计 Excel 导出仍实时查询。
|
||||
选课容量、成绩写入、考勤、审批、通知未读数、权限和后台任务状态仍直接以 MySQL 为准。
|
||||
|
||||
不配置 `ConnectionStrings__Redis` 时,开发和单机部署仍使用进程内缓存,不要求安装
|
||||
Redis。生产环境使用 Redis 时,通过环境变量配置连接串,例如:
|
||||
|
||||
```text
|
||||
ConnectionStrings__Redis=redis.internal:6380,user=jiaowu,password=REPLACE_ME,ssl=true,abortConnect=false
|
||||
```
|
||||
|
||||
仪表盘和统计摘要默认在 Redis 中缓存 3 分钟、进程内缓存 30 秒,可分别通过
|
||||
`Cache__AnalyticsExpirationMinutes` 和 `Cache__AnalyticsLocalExpirationSeconds`
|
||||
调整。该类汇总采用短 TTL 控制数据新鲜度,不要求每个业务写入点同步清理缓存。
|
||||
|
||||
Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库,普通启动和
|
||||
`/health/ready` 不依赖 Redis;可以单独检查 `/health/cache`。缓存键自动包含运行环境,
|
||||
同一 Redis 可以安全承载 Development、Staging 和 Production,但生产环境仍建议使用
|
||||
独立实例、私有网络、ACL 和 TLS。
|
||||
|
||||
`compose.example.yml` 包含不暴露宿主机端口的 Redis 服务,限制为 256 MB 并使用
|
||||
`allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis;
|
||||
如需连接外部 Redis,在 `.env` 中配置上述连接串即可。
|
||||
|
||||
## 跨平台发布与 Docker
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ x-jiaowu-environment: &jiaowu-environment
|
||||
Database__ApplyMigrationsOnStartup: "false"
|
||||
Database__CommandTimeoutSeconds: "30"
|
||||
ConnectionStrings__MySql: "Server=mysql;Port=3306;Database=${MYSQL_DATABASE:-jiaowu_demo};User=${MYSQL_USER:-jiaowu};Password=${MYSQL_PASSWORD:?请在 .env.docker 中设置 MYSQL_PASSWORD};SslMode=Disabled;"
|
||||
ConnectionStrings__Redis: "redis:6379,abortConnect=false"
|
||||
Cache__Enabled: "true"
|
||||
Cache__KeyPrefix: "jiaowu:v1"
|
||||
Jwt__Issuer: Jiaowu.Api
|
||||
Jwt__Audience: Jiaowu.Web
|
||||
Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}"
|
||||
@@ -30,6 +33,29 @@ x-json-logging: &json-logging
|
||||
max-file: "3"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:8.8-alpine
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --save
|
||||
- ""
|
||||
- --appendonly
|
||||
- "no"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lfu
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
logging: *json-logging
|
||||
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
@@ -69,6 +95,8 @@ services:
|
||||
<<: *jiaowu-image
|
||||
environment: *jiaowu-environment
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
|
||||
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var studentId = await GetStudentIdAsync(ct);
|
||||
if (studentId is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
studentId.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有免修申请在审核中。");
|
||||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
sid.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||
@@ -248,12 +254,75 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
// ═══════════════ Course Substitution ═══════════════
|
||||
[HttpGet("substitutions/mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new StudentCourseSubstitutionRecord(
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.Reason,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.OriginalCourse!.Code,
|
||||
x.OriginalCourse.Name,
|
||||
x.SubstituteCourse!.Code,
|
||||
x.SubstituteCourse.Name))
|
||||
.ToListAsync(ct));
|
||||
}
|
||||
|
||||
[HttpPost("substitutions")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (req.OriginalCourseId == req.SubstituteCourseId)
|
||||
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||||
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||||
.AnyAsync(task =>
|
||||
task.CourseId == req.OriginalCourseId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(record =>
|
||||
record.StudentId == sid.Value &&
|
||||
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
record.GradeSheet.TeachingTask!.CourseId ==
|
||||
req.OriginalCourseId &&
|
||||
record.TotalScore < 60,
|
||||
ct);
|
||||
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||||
if (!canReplaceOriginal)
|
||||
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||||
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(x =>
|
||||
x.StudentId == sid.Value &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||||
x.TotalScore >= 60,
|
||||
ct);
|
||||
if (!hasPassedSubstitute)
|
||||
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||||
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有替代申请在审核中。");
|
||||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
||||
@@ -319,16 +388,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Student enrolled courses ═══════════════
|
||||
// ═══════════════ Student course options ═══════════════
|
||||
[HttpGet("my-courses")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
var courses = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
|
||||
var courses = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)))
|
||||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new StudentApprovalCourseOption(
|
||||
x.Id,
|
||||
x.CourseId,
|
||||
x.TaskNumber,
|
||||
x.Course!.Code,
|
||||
x.Course.Name,
|
||||
x.Course.Credits,
|
||||
x.AcademicTerm!.Name,
|
||||
x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.ThenBy(item => item.Teacher!.Name)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == x.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||||
x.SchedulingMode))
|
||||
.ToListAsync(ct);
|
||||
return Ok(courses);
|
||||
}
|
||||
@@ -341,8 +444,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (sid is null) return StudentNotFound();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
|
||||
.OrderBy(x => x.CourseCode)
|
||||
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||
.Select(x => new StudentApprovalGradeOption(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Code,
|
||||
x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
x.ExamStatus,
|
||||
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.TaskNumber,
|
||||
x.GradeSheet.PublishedAt))
|
||||
.ToListAsync(ct);
|
||||
return Ok(grades);
|
||||
}
|
||||
@@ -377,6 +490,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||||
Guid studentId,
|
||||
Guid teachingTaskId,
|
||||
CancellationToken ct) =>
|
||||
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||||
task.Id == teachingTaskId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == studentId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == studentId &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
||||
@@ -390,6 +524,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||||
public sealed record StudentApprovalCourseOption(
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
string TaskNumber,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
string AcademicTermName,
|
||||
IEnumerable<string> TeacherNames,
|
||||
bool HasPublishedSchedule,
|
||||
TeachingTaskSchedulingMode SchedulingMode);
|
||||
public sealed record StudentApprovalGradeOption(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
decimal? GradePoint,
|
||||
GradeExamStatus ExamStatus,
|
||||
string AcademicTermName,
|
||||
string TaskNumber,
|
||||
DateTime? PublishedAt);
|
||||
public sealed record StudentCourseSubstitutionRecord(
|
||||
Guid Id,
|
||||
ApprovalStatus Status,
|
||||
string Reason,
|
||||
string? ReviewComment,
|
||||
DateTime SubmittedAt,
|
||||
string OriginalCourseCode,
|
||||
string OriginalCourseName,
|
||||
string SubstituteCourseCode,
|
||||
string SubstituteCourseName);
|
||||
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
||||
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -17,35 +18,51 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class AuthController(
|
||||
AppDbContext db,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService) : ControllerBase
|
||||
ITokenService tokenService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("activation-options")]
|
||||
public async Task<ActionResult> GetActivationOptions(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.ActivationOptions,
|
||||
async token =>
|
||||
{
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
.Select(x => new ActivationCollegeOption(x.Id, x.Code, x.Name))
|
||||
.ToListAsync(token);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
.Select(x => new ActivationMajorOption(
|
||||
x.Id, x.Code, x.Name, x.CollegeId))
|
||||
.ToListAsync(token);
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Major!.IsEnabled &&
|
||||
x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.Grade, x.MajorId })
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Colleges = colleges,
|
||||
Majors = majors,
|
||||
Classes = classes,
|
||||
Grades = classes.Select(x => x.Grade).Distinct().OrderByDescending(x => x)
|
||||
});
|
||||
.Select(x => new ActivationClassOption(
|
||||
x.Id, x.Code, x.Name, x.Grade, x.MajorId))
|
||||
.ToListAsync(token);
|
||||
return new ActivationOptionsResponse(
|
||||
colleges,
|
||||
majors,
|
||||
classes,
|
||||
classes.Select(x => x.Grade)
|
||||
.Distinct()
|
||||
.OrderByDescending(x => x)
|
||||
.ToList());
|
||||
},
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -219,3 +236,24 @@ public sealed record CurrentUserResponse(
|
||||
IEnumerable<string> Roles,
|
||||
Guid? CollegeId,
|
||||
string EffectiveDataScope);
|
||||
|
||||
public sealed record ActivationOptionsResponse(
|
||||
IReadOnlyList<ActivationCollegeOption> Colleges,
|
||||
IReadOnlyList<ActivationMajorOption> Majors,
|
||||
IReadOnlyList<ActivationClassOption> Classes,
|
||||
IReadOnlyList<int> Grades);
|
||||
|
||||
public sealed record ActivationCollegeOption(Guid Id, string Code, string Name);
|
||||
|
||||
public sealed record ActivationMajorOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId);
|
||||
|
||||
public sealed record ActivationClassOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Grade,
|
||||
Guid MajorId);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/base-data")]
|
||||
public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Administrators =
|
||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||
@@ -20,9 +21,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
[HttpGet("campuses")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<Campus>>> GetCampuses(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.Campuses.AsNoTracking()
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("campuses"),
|
||||
token => db.Campuses.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("campuses")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -52,21 +58,32 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
if (entity is null) return NotFound();
|
||||
ApplyCatalog(entity, request);
|
||||
entity.Address = request.Description?.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("colleges")]
|
||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Colleges.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
public async Task<ActionResult<object>> GetColleges(CancellationToken cancellationToken)
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.ShortName, x.CampusId,
|
||||
CampusName = x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("colleges"),
|
||||
token => db.Colleges.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new CollegeListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.ShortName,
|
||||
x.CampusId,
|
||||
x.Campus != null ? x.Campus.Name : null,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("colleges")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -104,29 +121,46 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
ApplyCatalog(entity, request);
|
||||
entity.ShortName = request.ShortName?.Trim();
|
||||
entity.CampusId = request.CampusId;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("majors")]
|
||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Majors.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
public async Task<ActionResult<object>> GetMajors(CancellationToken cancellationToken)
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.CollegeId,
|
||||
CollegeName = x.College!.Name,
|
||||
x.DegreeType, x.SchoolingYears, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("majors"),
|
||||
token => db.Majors.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new MajorListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CollegeId,
|
||||
x.College!.Name,
|
||||
x.DegreeType,
|
||||
x.SchoolingYears,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("course-categories")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<CourseCategory>>> GetCourseCategories(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.CourseCategories.AsNoTracking()
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("course-categories"),
|
||||
token => db.CourseCategories.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("course-categories")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -154,7 +188,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
var entity = await db.CourseCategories.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
ApplyCatalog(entity, request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -193,23 +227,35 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.CollegeId = request.CollegeId;
|
||||
entity.DegreeType = request.DegreeType.Trim();
|
||||
entity.SchoolingYears = request.SchoolingYears;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classes")]
|
||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken) =>
|
||||
Ok(await db.AdministrativeClasses.AsNoTracking()
|
||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
public async Task<ActionResult<object>> GetClasses(CancellationToken cancellationToken)
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
x.Grade, x.CounselorUserId, x.CounselorName,
|
||||
x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("classes"),
|
||||
token => db.AdministrativeClasses.AsNoTracking()
|
||||
.OrderByDescending(x => x.Grade).ThenBy(x => x.Code)
|
||||
.Select(x => new AdministrativeClassListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.MajorId,
|
||||
x.Major!.Name,
|
||||
x.Major.College!.Name,
|
||||
x.Grade,
|
||||
x.CounselorUserId,
|
||||
x.CounselorName,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("counselors")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -279,18 +325,23 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.Grade = request.Grade;
|
||||
entity.CounselorUserId = request.CounselorUserId;
|
||||
entity.CounselorName = counselorName;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("terms")]
|
||||
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AcademicTerms.AsNoTracking()
|
||||
await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("terms"),
|
||||
token => db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("terms")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -360,7 +411,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.StartDate = request.StartDate;
|
||||
entity.EndDate = request.EndDate;
|
||||
entity.IsCurrent = request.IsCurrent;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -384,7 +435,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
entity.IsCurrent = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -402,7 +453,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
|
||||
entity.IsArchived = true;
|
||||
entity.ArchivedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -418,35 +469,59 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
|
||||
entity.IsArchived = false;
|
||||
entity.ArchivedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classrooms")]
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Classrooms.AsNoTracking()
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("classrooms"),
|
||||
token => db.Classrooms.AsNoTracking()
|
||||
.OrderBy(x => x.Building!.Campus!.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.BuildingId,
|
||||
CampusId = x.Building!.CampusId,
|
||||
BuildingName = x.Building!.Name,
|
||||
CampusName = x.Building.Campus!.Name,
|
||||
x.Capacity, x.RoomType, x.Equipment, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
.Select(x => new ClassroomListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.BuildingId,
|
||||
x.Building!.CampusId,
|
||||
x.Building.Name,
|
||||
x.Building.Campus!.Name,
|
||||
x.Capacity,
|
||||
x.RoomType,
|
||||
x.Equipment,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("buildings")]
|
||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Buildings.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
public async Task<ActionResult<object>> GetBuildings(CancellationToken cancellationToken)
|
||||
{
|
||||
x.Id, x.Code, x.Name, x.CampusId,
|
||||
CampusName = x.Campus!.Name, x.IsEnabled, x.SortOrder
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.BaseData("buildings"),
|
||||
token => db.Buildings.AsNoTracking()
|
||||
.OrderBy(x => x.SortOrder).ThenBy(x => x.Code)
|
||||
.Select(x => new BuildingListItem(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.CampusId,
|
||||
x.Campus!.Name,
|
||||
x.IsEnabled,
|
||||
x.SortOrder))
|
||||
.ToListAsync(token),
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("buildings")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
@@ -503,7 +578,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.Capacity = request.Capacity;
|
||||
entity.RoomType = request.RoomType.Trim();
|
||||
entity.Equipment = request.Equipment?.Trim();
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -533,7 +608,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
db.Remove(entity);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -556,7 +631,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
db.Add(entity);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await SaveAndInvalidateAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
@@ -566,6 +641,12 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
return CreatedAtAction(action, new { id = entity.Id }, entity);
|
||||
}
|
||||
|
||||
private async Task SaveAndInvalidateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.BaseData, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> GetCounselorNameAsync(
|
||||
Guid? userId,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -649,3 +730,60 @@ public sealed record ClassroomRequest(
|
||||
[Required, MaxLength(40)] string RoomType,
|
||||
[MaxLength(300)] string? Equipment)
|
||||
: CatalogRequest(Code, Name, SortOrder, IsEnabled);
|
||||
|
||||
public sealed record CollegeListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? ShortName,
|
||||
Guid? CampusId,
|
||||
string? CampusName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record MajorListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId,
|
||||
string CollegeName,
|
||||
string DegreeType,
|
||||
int SchoolingYears,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record AdministrativeClassListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
string CollegeName,
|
||||
int Grade,
|
||||
Guid? CounselorUserId,
|
||||
string? CounselorName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record BuildingListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CampusId,
|
||||
string CampusName,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record ClassroomListItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid BuildingId,
|
||||
Guid CampusId,
|
||||
string BuildingName,
|
||||
string CampusName,
|
||||
int Capacity,
|
||||
string RoomType,
|
||||
string? Equipment,
|
||||
bool IsEnabled,
|
||||
int SortOrder);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,7 +13,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = Administrators)]
|
||||
[Route("api/base-data")]
|
||||
public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Administrators =
|
||||
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
|
||||
@@ -114,6 +115,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.BaseData,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/courses")]
|
||||
public sealed class CoursesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string WriteRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -273,6 +275,7 @@ public sealed class CoursesController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -15,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/courses")]
|
||||
public sealed class CoursesExcelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string WriteRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -141,6 +143,9 @@ public sealed class CoursesExcelController(
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/dashboard")]
|
||||
public sealed class DashboardController(AppDbContext db) : ControllerBase
|
||||
public sealed class DashboardController(
|
||||
AppDbContext db,
|
||||
IAppCache appCache,
|
||||
IOptions<JsonOptions> jsonOptions) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await appCache.GetOrCreateAsync(
|
||||
AppCacheKeys.Dashboard,
|
||||
LoadAsync,
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.Analytics],
|
||||
cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var currentTerm = await db.AcademicTerms
|
||||
.AsNoTracking()
|
||||
@@ -20,7 +37,8 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
||||
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return new
|
||||
return JsonSerializer.SerializeToElement(
|
||||
new
|
||||
{
|
||||
CurrentTerm = currentTerm,
|
||||
Counts = new
|
||||
@@ -78,6 +96,7 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
||||
cancellationToken),
|
||||
Users = await db.Users.CountAsync(cancellationToken)
|
||||
}
|
||||
};
|
||||
},
|
||||
jsonOptions.Value.JsonSerializerOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -16,7 +17,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class ExamsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
ExamArrangementService examArrangementService) : ControllerBase
|
||||
ExamArrangementService examArrangementService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string Managers =
|
||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||
@@ -610,6 +612,7 @@ public sealed class ExamsController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(token);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, token);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
public sealed class PersonnelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
UserManager<ApplicationUser> userManager) : ControllerBase
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -209,6 +211,9 @@ public sealed class PersonnelController(
|
||||
teacher.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -429,6 +434,9 @@ public sealed class PersonnelController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -442,6 +450,9 @@ public sealed class PersonnelController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,7 +15,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/personnel")]
|
||||
public sealed class PersonnelExcelController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ReadRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -142,6 +144,9 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,7 +12,7 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin)]
|
||||
[Route("api/schedules")]
|
||||
public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("time-slots")]
|
||||
public async Task<ActionResult> GetTimeSlots(
|
||||
@@ -65,6 +66,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
IsEnabled = request.IsEnabled
|
||||
}));
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/statistics")]
|
||||
public sealed class StatisticsController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache appCache) : ControllerBase
|
||||
{
|
||||
private const string ViewerRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -34,14 +37,49 @@ public sealed class StatisticsController(
|
||||
// ── 1. Student Statistics ────────────────────────────────────────
|
||||
|
||||
[HttpGet("students/summary")]
|
||||
public async Task<ActionResult<object>> GetStudentSummary(
|
||||
public Task<ActionResult<object>> GetStudentSummary(
|
||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
int? grade, int? enrollmentYear,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetStudentSummaryCore(
|
||||
Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
int? grade, int? enrollmentYear,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"students",
|
||||
effectiveCollegeId,
|
||||
KeyPart(majorId),
|
||||
KeyPart(classId),
|
||||
KeyPart(grade),
|
||||
KeyPart(enrollmentYear)),
|
||||
token => GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var baseQuery = db.Students.AsNoTracking()
|
||||
.Where(s => effectiveCollegeId == null ||
|
||||
s.AdministrativeClass!.Major!.CollegeId == effectiveCollegeId)
|
||||
@@ -112,8 +150,16 @@ public sealed class StatisticsController(
|
||||
int? grade, int? enrollmentYear,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetStudentSummary(collegeId, majorId, classId, grade, enrollmentYear, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetStudentSummaryCore(
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
grade,
|
||||
enrollmentYear,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("学生统计", new()
|
||||
{
|
||||
{ "各学院人数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
@@ -128,13 +174,41 @@ public sealed class StatisticsController(
|
||||
// ── 2. Course Statistics ─────────────────────────────────────────
|
||||
|
||||
[HttpGet("courses/summary")]
|
||||
public async Task<ActionResult<object>> GetCourseSummary(
|
||||
public Task<ActionResult<object>> GetCourseSummary(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetCourseSummaryCore(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"courses",
|
||||
effectiveCollegeId,
|
||||
KeyPart(categoryId),
|
||||
KeyPart(nature)),
|
||||
token => GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var baseQuery = db.Courses.AsNoTracking()
|
||||
.Where(c => effectiveCollegeId == null || c.CollegeId == effectiveCollegeId)
|
||||
.Where(c => categoryId == null || c.CourseCategoryId == categoryId)
|
||||
@@ -184,8 +258,14 @@ public sealed class StatisticsController(
|
||||
Guid? collegeId, Guid? categoryId, CourseNature? nature,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetCourseSummary(collegeId, categoryId, nature, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetCourseSummaryCore(
|
||||
collegeId,
|
||||
categoryId,
|
||||
nature,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("课程统计", new()
|
||||
{
|
||||
{ "各学院课程数", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.count }) },
|
||||
@@ -199,13 +279,46 @@ public sealed class StatisticsController(
|
||||
// ── 3. Grade Statistics ──────────────────────────────────────────
|
||||
|
||||
[HttpGet("grades/summary")]
|
||||
public async Task<ActionResult<object>> GetGradeSummary(
|
||||
public Task<ActionResult<object>> GetGradeSummary(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, CancellationToken cancellationToken)
|
||||
Guid? courseId, CancellationToken cancellationToken) =>
|
||||
GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetGradeSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, bool useCache, CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"grades",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(majorId),
|
||||
KeyPart(classId),
|
||||
KeyPart(courseId)),
|
||||
token => GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||
@@ -302,8 +415,16 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? majorId, Guid? classId,
|
||||
Guid? courseId, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetGradeSummary(academicTermId, collegeId, majorId, classId, courseId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetGradeSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
majorId,
|
||||
classId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("成绩统计", new()
|
||||
{
|
||||
{ "分数段分布", ((IEnumerable<dynamic>)data.scoreDistribution).Select(x => new object?[] { x.label, x.count }) },
|
||||
@@ -317,13 +438,41 @@ public sealed class StatisticsController(
|
||||
// ── 4. Pass Rate Statistics ──────────────────────────────────────
|
||||
|
||||
[HttpGet("pass-rates/summary")]
|
||||
public async Task<ActionResult<object>> GetPassRateSummary(
|
||||
public Task<ActionResult<object>> GetPassRateSummary(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetPassRateSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"pass-rates",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(courseId)),
|
||||
token => GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var recordsQuery = db.GradeRecords.AsNoTracking()
|
||||
.Where(r => r.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(r => academicTermId == null || r.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId)
|
||||
@@ -425,8 +574,14 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId, Guid? courseId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetPassRateSummary(academicTermId, collegeId, courseId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetPassRateSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
courseId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("通过率统计", new()
|
||||
{
|
||||
{ "各学院通过率", ((IEnumerable<dynamic>)data.byCollege).Select(x => new object?[] { x.collegeName, x.passRate, x.total }) },
|
||||
@@ -439,13 +594,38 @@ public sealed class StatisticsController(
|
||||
// ── 5. Teacher Workload Statistics ───────────────────────────────
|
||||
|
||||
[HttpGet("teacher-workload/summary")]
|
||||
public async Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||
public Task<ActionResult<object>> GetTeacherWorkloadSummary(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetTeacherWorkloadSummaryCore(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveCollegeId = ResolveCollegeId(collegeId);
|
||||
if (collegeId.HasValue && effectiveCollegeId != collegeId) return Forbid();
|
||||
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"teacher-workload",
|
||||
effectiveCollegeId,
|
||||
KeyPart(academicTermId)),
|
||||
token => GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var tasks = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(tt => academicTermId == null || tt.TeachingTask!.AcademicTermId == academicTermId)
|
||||
.Where(tt => effectiveCollegeId == null || tt.Teacher!.CollegeId == effectiveCollegeId)
|
||||
@@ -524,8 +704,13 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? collegeId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetTeacherWorkloadSummary(academicTermId, collegeId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetTeacherWorkloadSummaryCore(
|
||||
academicTermId,
|
||||
collegeId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("教师工作量统计", new()
|
||||
{
|
||||
{ "教师明细", ((IEnumerable<dynamic>)data.byTeacher).Select(x => new object?[] { x.teacherName, x.teacherNumber, x.collegeName, x.title, x.totalHours, x.courseCount, x.taskCount }) },
|
||||
@@ -537,10 +722,39 @@ public sealed class StatisticsController(
|
||||
// ── 6. Classroom Utilization Statistics ──────────────────────────
|
||||
|
||||
[HttpGet("classroom-utilization/summary")]
|
||||
public async Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||
public Task<ActionResult<object>> GetClassroomUtilizationSummary(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
CancellationToken cancellationToken) =>
|
||||
GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<object>> GetClassroomUtilizationSummaryCore(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
bool useCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (useCache)
|
||||
{
|
||||
return await GetCachedSummaryAsync(
|
||||
StatisticsKey(
|
||||
"classroom-utilization",
|
||||
RestrictedCollegeId,
|
||||
KeyPart(academicTermId),
|
||||
KeyPart(buildingId),
|
||||
KeyPart(campusId)),
|
||||
token => GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: false,
|
||||
token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Find published schedule plan
|
||||
var planQuery = db.SchedulePlans.AsNoTracking()
|
||||
.Where(p => p.Status == SchedulePlanStatus.Published);
|
||||
@@ -713,8 +927,14 @@ public sealed class StatisticsController(
|
||||
Guid? academicTermId, Guid? buildingId, Guid? campusId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var data = (dynamic)(await GetClassroomUtilizationSummary(academicTermId, buildingId, campusId, cancellationToken)
|
||||
.ConfigureAwait(false)).Value!;
|
||||
var summary = await GetClassroomUtilizationSummaryCore(
|
||||
academicTermId,
|
||||
buildingId,
|
||||
campusId,
|
||||
useCache: false,
|
||||
cancellationToken);
|
||||
if (summary.Result is not null) return summary.Result;
|
||||
var data = (dynamic)summary.Value!;
|
||||
return ExportSummary("教室利用率统计", new()
|
||||
{
|
||||
{ "各教学楼", ((IEnumerable<dynamic>)data.byBuilding).Select(x => new object?[] { x.buildingName, x.totalClassrooms, x.utilizationRate, x.totalUsedPeriods, x.totalAvailablePeriods }) },
|
||||
@@ -727,6 +947,50 @@ public sealed class StatisticsController(
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<ActionResult<object>> GetCachedSummaryAsync(
|
||||
string key,
|
||||
Func<CancellationToken, Task<ActionResult<object>>> factory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await appCache.GetOrCreateAsync(
|
||||
key,
|
||||
async token =>
|
||||
{
|
||||
var source = await factory(token);
|
||||
if (source.Result is not null || source.Value is null)
|
||||
throw new InvalidOperationException(
|
||||
"Statistics cache source did not return a successful value.");
|
||||
|
||||
return JsonSerializer.SerializeToElement(
|
||||
source.Value,
|
||||
source.Value.GetType());
|
||||
},
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.Analytics],
|
||||
cancellationToken);
|
||||
return value;
|
||||
}
|
||||
|
||||
private string StatisticsKey(
|
||||
string area,
|
||||
Guid? effectiveCollegeId,
|
||||
params string?[] filters) =>
|
||||
AppCacheKeys.Statistics(
|
||||
area,
|
||||
currentUserDataScope.Current.Scope.ToString(),
|
||||
effectiveCollegeId,
|
||||
filters);
|
||||
|
||||
private static string KeyPart(Guid? value) =>
|
||||
value?.ToString("N") ?? "-";
|
||||
|
||||
private static string KeyPart(int? value) =>
|
||||
value?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-";
|
||||
|
||||
private static string KeyPart<TEnum>(TEnum? value)
|
||||
where TEnum : struct, Enum =>
|
||||
value?.ToString() ?? "-";
|
||||
|
||||
private FileContentResult ExportSummary(
|
||||
string title,
|
||||
Dictionary<string, IEnumerable<object?[]>> sheets)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Contracts;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Teaching;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -17,7 +18,8 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/teaching-tasks")]
|
||||
public sealed class TeachingTasksController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
ICurrentUserDataScope currentUserDataScope,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
private const string ManagementRoles =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
@@ -389,6 +391,7 @@ public sealed class TeachingTasksController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return Ok(new { AffectedCount = tasks.Count });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
@@ -544,6 +547,9 @@ public sealed class TeachingTasksController(
|
||||
db.TeachingTasks.AddRange(created);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(
|
||||
AppCacheTags.Timetables,
|
||||
cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
BatchCode = batchCode,
|
||||
@@ -737,6 +743,7 @@ public sealed class TeachingTasksController(
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Excel;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
@@ -14,74 +15,24 @@ namespace Jiaowu.Api.Controllers;
|
||||
[Route("api/timetables")]
|
||||
public sealed class TimetablesController(
|
||||
AppDbContext db,
|
||||
TimetableDataService timetableDataService) : ControllerBase
|
||||
TimetableDataService timetableDataService,
|
||||
IAppCache cache) : ControllerBase
|
||||
{
|
||||
[HttpGet("options")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> GetOptions(CancellationToken cancellationToken)
|
||||
{
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicYear,
|
||||
x.Season,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
x.MajorId,
|
||||
MajorName = x.Major!.Name,
|
||||
CollegeId = x.Major.CollegeId,
|
||||
CollegeName = x.Major.College!.Name,
|
||||
HasPublishedTimetable = defaultTermId.HasValue &&
|
||||
(db.ScheduleEntries.Any(entry =>
|
||||
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name, x.CollegeId })
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Terms = terms, Colleges = colleges, Majors = majors, Classes = classes });
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
AppCacheKeys.TimetableOptions,
|
||||
LoadOptionsAsync,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[
|
||||
AppCacheTags.BaseData,
|
||||
AppCacheTags.Timetables,
|
||||
AppCacheTags.TimetableOptions
|
||||
],
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("classes/{classId:guid}")]
|
||||
@@ -99,14 +50,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacherId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
@@ -118,14 +65,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Teacher,
|
||||
teacherId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
return ExcelFile(result);
|
||||
@@ -138,14 +81,10 @@ public sealed class TimetablesController(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
cancellationToken);
|
||||
if (result is null) return NotFound();
|
||||
return ExcelFile(result);
|
||||
@@ -269,7 +208,8 @@ public sealed class TimetablesController(
|
||||
CancellationToken cancellationToken,
|
||||
TimetableStudentDto? student = null)
|
||||
{
|
||||
var result = await timetableDataService.BuildAsync(
|
||||
var result = studentId.HasValue || student is not null
|
||||
? await timetableDataService.BuildAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
@@ -277,10 +217,102 @@ public sealed class TimetablesController(
|
||||
false,
|
||||
studentId,
|
||||
student,
|
||||
cancellationToken)
|
||||
: await GetPublishedTimetableAsync(
|
||||
TimetableResourceType.Class,
|
||||
classId,
|
||||
academicTermId,
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private Task<TimetableData?> GetPublishedTimetableAsync(
|
||||
TimetableResourceType resourceType,
|
||||
Guid resourceId,
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken) =>
|
||||
cache.GetOrCreateAsync(
|
||||
AppCacheKeys.PublishedTimetable(
|
||||
resourceType.ToString().ToLowerInvariant(),
|
||||
resourceId,
|
||||
academicTermId),
|
||||
token => timetableDataService.BuildAsync(
|
||||
resourceType,
|
||||
resourceId,
|
||||
academicTermId,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
token),
|
||||
AppCacheProfile.PublishedTimetable,
|
||||
[AppCacheTags.BaseData, AppCacheTags.Timetables],
|
||||
cancellationToken);
|
||||
|
||||
private async Task<TimetableOptionsResponse> LoadOptionsAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.Select(x => new TimetableTermOption(
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicYear,
|
||||
x.Season,
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == x.Id &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)))
|
||||
.ToListAsync(cancellationToken);
|
||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var classes = await db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.Major!.IsEnabled && x.Major.College!.IsEnabled)
|
||||
.OrderByDescending(x => x.Grade)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new TimetableClassOption(
|
||||
x.Id,
|
||||
x.Code,
|
||||
x.Name,
|
||||
x.Grade,
|
||||
x.MajorId,
|
||||
x.Major!.Name,
|
||||
x.Major.CollegeId,
|
||||
x.Major.College!.Name,
|
||||
defaultTermId.HasValue &&
|
||||
(db.ScheduleEntries.Any(entry =>
|
||||
entry.SchedulePlan!.AcademicTermId == defaultTermId.Value &&
|
||||
entry.SchedulePlan.Status == SchedulePlanStatus.Published &&
|
||||
entry.TeachingTask!.Classes.Any(item =>
|
||||
item.AdministrativeClassId == x.Id)) ||
|
||||
db.TeachingTasks.Any(task =>
|
||||
task.AcademicTermId == defaultTermId.Value &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))))
|
||||
.ToListAsync(cancellationToken);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new TimetableCollegeOption(x.Id, x.Code, x.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
var majors = await db.Majors.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && x.College!.IsEnabled)
|
||||
.OrderBy(x => x.Code)
|
||||
.Select(x => new TimetableMajorOption(
|
||||
x.Id, x.Code, x.Name, x.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
return new TimetableOptionsResponse(terms, colleges, majors, classes);
|
||||
}
|
||||
|
||||
private ActionResult ExcelFile(TimetableData result)
|
||||
{
|
||||
var bytes = TimetableExcelExporter.Create(result);
|
||||
@@ -296,3 +328,39 @@ public sealed class TimetablesController(
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record TimetableOptionsResponse(
|
||||
IReadOnlyList<TimetableTermOption> Terms,
|
||||
IReadOnlyList<TimetableCollegeOption> Colleges,
|
||||
IReadOnlyList<TimetableMajorOption> Majors,
|
||||
IReadOnlyList<TimetableClassOption> Classes);
|
||||
|
||||
public sealed record TimetableTermOption(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string AcademicYear,
|
||||
TermSeason Season,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
bool IsCurrent,
|
||||
bool IsArchived,
|
||||
bool HasPublishedTimetable);
|
||||
|
||||
public sealed record TimetableCollegeOption(Guid Id, string Code, string Name);
|
||||
|
||||
public sealed record TimetableMajorOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
Guid CollegeId);
|
||||
|
||||
public sealed record TimetableClassOption(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Grade,
|
||||
Guid MajorId,
|
||||
string MajorName,
|
||||
Guid CollegeId,
|
||||
string CollegeName,
|
||||
bool HasPublishedTimetable);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Caching.Hybrid;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public enum AppCacheProfile
|
||||
{
|
||||
ReferenceData,
|
||||
PublishedTimetable,
|
||||
Analytics
|
||||
}
|
||||
|
||||
public interface IAppCache
|
||||
{
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class HybridAppCache(
|
||||
HybridCache cache,
|
||||
AppCacheOptions options,
|
||||
IHostEnvironment environment,
|
||||
ILogger<HybridAppCache> logger) : IAppCache
|
||||
{
|
||||
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return await factory(cancellationToken);
|
||||
|
||||
var sourceCompleted = false;
|
||||
T? sourceValue = default;
|
||||
try
|
||||
{
|
||||
return await cache.GetOrCreateAsync(
|
||||
$"{prefix}:{key}",
|
||||
async token =>
|
||||
{
|
||||
sourceValue = await factory(token);
|
||||
sourceCompleted = true;
|
||||
return sourceValue;
|
||||
},
|
||||
GetEntryOptions(profile),
|
||||
tags.Select(tag => $"{prefix}:tag:{tag}"),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Redis cache operation failed for {CacheKey}; using the source directly.",
|
||||
key);
|
||||
if (sourceCompleted)
|
||||
return sourceValue!;
|
||||
return await factory(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await cache.RemoveByTagAsync(
|
||||
$"{prefix}:tag:{tag}",
|
||||
cancellationToken);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.",
|
||||
tag);
|
||||
}
|
||||
}
|
||||
|
||||
private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) =>
|
||||
profile switch
|
||||
{
|
||||
AppCacheProfile.ReferenceData => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds)
|
||||
},
|
||||
AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
|
||||
},
|
||||
AppCacheProfile.Analytics => new HybridCacheEntryOptions
|
||||
{
|
||||
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
|
||||
LocalCacheExpiration =
|
||||
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
|
||||
};
|
||||
|
||||
private static string BuildPrefix(string configuredPrefix, string environmentName)
|
||||
{
|
||||
var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix)
|
||||
? "jiaowu:v1"
|
||||
: configuredPrefix.Trim().Trim(':');
|
||||
var environmentValue = string.Concat(
|
||||
environmentName.Trim().ToLowerInvariant()
|
||||
.Select(character => char.IsLetterOrDigit(character) ? character : '-'));
|
||||
return $"{prefixValue}:{environmentValue}";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpAppCache : IAppCache
|
||||
{
|
||||
public static NoOpAppCache Instance { get; } = new();
|
||||
|
||||
private NoOpAppCache()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken) =>
|
||||
factory(cancellationToken);
|
||||
|
||||
public ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public static class AppCacheKeys
|
||||
{
|
||||
public const string ActivationOptions = "auth:activation-options";
|
||||
public const string Dashboard = "dashboard:summary";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string BaseData(string kind) => $"base-data:{kind}";
|
||||
|
||||
public static string PublishedTimetable(
|
||||
string resourceType,
|
||||
Guid resourceId,
|
||||
Guid? academicTermId) =>
|
||||
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
||||
$"{resourceType}:{resourceId:N}";
|
||||
|
||||
public static string Statistics(
|
||||
string area,
|
||||
string dataScope,
|
||||
Guid? effectiveCollegeId,
|
||||
params string?[] filters)
|
||||
{
|
||||
static string Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? "-"
|
||||
: value.Trim().ToLowerInvariant();
|
||||
|
||||
var filterPart = filters.Length == 0
|
||||
? "all"
|
||||
: string.Join(':', filters.Select(Normalize));
|
||||
return $"statistics:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
||||
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
||||
}
|
||||
}
|
||||
|
||||
public static class AppCacheTags
|
||||
{
|
||||
public const string BaseData = "base-data";
|
||||
public const string Analytics = "analytics";
|
||||
public const string Timetables = "timetables";
|
||||
public const string TimetableOptions = "timetable:options";
|
||||
|
||||
public static string Timetable(Guid academicTermId) =>
|
||||
$"timetable:term:{academicTermId:N}";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Jiaowu.Api.Infrastructure.Caching;
|
||||
|
||||
public sealed class AppCacheOptions
|
||||
{
|
||||
public const string SectionName = "Cache";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string KeyPrefix { get; set; } = "jiaowu:v1";
|
||||
public int ReferenceExpirationMinutes { get; set; } = 30;
|
||||
public int ReferenceLocalExpirationSeconds { get; set; } = 120;
|
||||
public int TimetableExpirationMinutes { get; set; } = 10;
|
||||
public int TimetableLocalExpirationSeconds { get; set; } = 30;
|
||||
public int AnalyticsExpirationMinutes { get; set; } = 3;
|
||||
public int AnalyticsLocalExpirationSeconds { get; set; } = 30;
|
||||
public int MaximumPayloadKilobytes { get; set; } = 2048;
|
||||
}
|
||||
@@ -101,6 +101,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
configurationBuilder.Properties<DateOnly>()
|
||||
.HaveConversion<DateOnlyDateTimeConverter>()
|
||||
.HaveColumnType("date");
|
||||
|
||||
// Connector/NET returns MySQL TIME values as TimeSpan. Convert at the
|
||||
// provider boundary so schedule time slots can still use TimeOnly.
|
||||
configurationBuilder.Properties<TimeOnly>()
|
||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||
.HaveColumnType("time");
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
@@ -931,3 +937,8 @@ public sealed class DateOnlyDateTimeConverter()
|
||||
: ValueConverter<DateOnly, DateTime>(
|
||||
date => date.ToDateTime(TimeOnly.MinValue),
|
||||
value => DateOnly.FromDateTime(value));
|
||||
|
||||
public sealed class TimeOnlyTimeSpanConverter()
|
||||
: ValueConverter<TimeOnly, TimeSpan>(
|
||||
time => time.ToTimeSpan(),
|
||||
value => TimeOnly.FromTimeSpan(value));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -103,6 +104,7 @@ public sealed class SchedulePublishJobWorker(
|
||||
public sealed class SchedulePublishJobProcessor(
|
||||
AppDbContext db,
|
||||
SchedulePlanPublisher publisher,
|
||||
IAppCache cache,
|
||||
ILogger<SchedulePublishJobProcessor> logger)
|
||||
{
|
||||
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||
@@ -177,6 +179,7 @@ public sealed class SchedulePublishJobProcessor(
|
||||
},
|
||||
stoppingToken);
|
||||
|
||||
await cache.RemoveByTagAsync(AppCacheTags.Timetables, stoppingToken);
|
||||
logger.LogInformation(
|
||||
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||
jobId,
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Configuration;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Exams;
|
||||
using Jiaowu.Api.Infrastructure.Middleware;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
@@ -12,6 +13,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Threading.RateLimiting;
|
||||
@@ -61,6 +63,9 @@ if (seedDemoData && builder.Environment.IsDevelopment())
|
||||
var databaseOptions = builder.Configuration
|
||||
.GetSection(DatabaseOptions.SectionName)
|
||||
.Get<DatabaseOptions>() ?? new DatabaseOptions();
|
||||
var cacheOptions = builder.Configuration
|
||||
.GetSection(AppCacheOptions.SectionName)
|
||||
.Get<AppCacheOptions>() ?? new AppCacheOptions();
|
||||
|
||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) &&
|
||||
!builder.Environment.IsDevelopment())
|
||||
@@ -82,7 +87,28 @@ if (databaseOptions.CommandTimeoutSeconds is < 5 or > 300)
|
||||
"Database:CommandTimeoutSeconds 必须在 5 到 300 秒之间。");
|
||||
}
|
||||
|
||||
if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.TimetableExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.AnalyticsExpirationMinutes is < 1 or > 1440 ||
|
||||
cacheOptions.ReferenceLocalExpirationSeconds is < 1 or > 3600 ||
|
||||
cacheOptions.TimetableLocalExpirationSeconds is < 1 or > 3600 ||
|
||||
cacheOptions.AnalyticsLocalExpirationSeconds is < 1 or > 3600 ||
|
||||
cacheOptions.MaximumPayloadKilobytes is < 64 or > 16384 ||
|
||||
cacheOptions.ReferenceLocalExpirationSeconds >
|
||||
cacheOptions.ReferenceExpirationMinutes * 60 ||
|
||||
cacheOptions.TimetableLocalExpirationSeconds >
|
||||
cacheOptions.TimetableExpirationMinutes * 60 ||
|
||||
cacheOptions.AnalyticsLocalExpirationSeconds >
|
||||
cacheOptions.AnalyticsExpirationMinutes * 60 ||
|
||||
string.IsNullOrWhiteSpace(cacheOptions.KeyPrefix) ||
|
||||
cacheOptions.KeyPrefix.Length > 100)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。");
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(databaseOptions);
|
||||
builder.Services.AddSingleton(cacheOptions);
|
||||
builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
{
|
||||
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -125,6 +151,19 @@ builder.Services.AddDbContextPool<AppDbContext>(options =>
|
||||
});
|
||||
});
|
||||
|
||||
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
|
||||
if (cacheOptions.Enabled && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
options.Configuration = redisConnectionString);
|
||||
}
|
||||
builder.Services.AddHybridCache(options =>
|
||||
{
|
||||
options.MaximumKeyLength = 512;
|
||||
options.MaximumPayloadBytes = cacheOptions.MaximumPayloadKilobytes * 1024;
|
||||
});
|
||||
builder.Services.AddSingleton<IAppCache, HybridAppCache>();
|
||||
|
||||
builder.Services
|
||||
.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
@@ -320,6 +359,7 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" }))
|
||||
.AllowAnonymous();
|
||||
app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||
app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous();
|
||||
app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous();
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api") ||
|
||||
@@ -383,4 +423,27 @@ static async Task<IResult> CheckDatabaseHealthAsync(
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IResult> CheckCacheHealthAsync(
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var distributedCache = services.GetService<IDistributedCache>();
|
||||
if (distributedCache is null)
|
||||
return Results.Ok(new { Status = "disabled", Backend = "memory" });
|
||||
|
||||
try
|
||||
{
|
||||
await distributedCache.GetAsync(
|
||||
"jiaowu:health:probe",
|
||||
cancellationToken);
|
||||
return Results.Ok(new { Status = "healthy", Backend = "redis" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(
|
||||
new { Status = "unhealthy", Backend = "redis" },
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"ConnectionStrings": {
|
||||
"SQLite": "Data Source=data/jiaowu-dev.sqlite"
|
||||
},
|
||||
"Cache": {
|
||||
"Enabled": true
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "jiaowu-development-secret-key-change-before-production",
|
||||
"ExpireMinutes": 480
|
||||
|
||||
@@ -5,7 +5,19 @@
|
||||
"CommandTimeoutSeconds": 30
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"MySql": ""
|
||||
"MySql": "",
|
||||
"Redis": ""
|
||||
},
|
||||
"Cache": {
|
||||
"Enabled": true,
|
||||
"KeyPrefix": "jiaowu:v1",
|
||||
"ReferenceExpirationMinutes": 30,
|
||||
"ReferenceLocalExpirationSeconds": 120,
|
||||
"TimetableExpirationMinutes": 10,
|
||||
"TimetableLocalExpirationSeconds": 30,
|
||||
"AnalyticsExpirationMinutes": 3,
|
||||
"AnalyticsLocalExpirationSeconds": 30,
|
||||
"MaximumPayloadKilobytes": 2048
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "Jiaowu.Api",
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Text.Json;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Timetables;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AppCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void Statistics_keys_isolate_data_scope_college_and_filters()
|
||||
{
|
||||
var firstCollege = Guid.NewGuid();
|
||||
var secondCollege = Guid.NewGuid();
|
||||
var term = Guid.NewGuid().ToString("N");
|
||||
|
||||
var first = AppCacheKeys.Statistics(
|
||||
"grades",
|
||||
"College",
|
||||
firstCollege,
|
||||
term,
|
||||
"-");
|
||||
var otherCollege = AppCacheKeys.Statistics(
|
||||
"grades",
|
||||
"College",
|
||||
secondCollege,
|
||||
term,
|
||||
"-");
|
||||
var otherScope = AppCacheKeys.Statistics(
|
||||
"grades",
|
||||
"All",
|
||||
firstCollege,
|
||||
term,
|
||||
"-");
|
||||
var otherFilter = AppCacheKeys.Statistics(
|
||||
"grades",
|
||||
"College",
|
||||
firstCollege,
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"-");
|
||||
|
||||
Assert.NotEqual(first, otherCollege);
|
||||
Assert.NotEqual(first, otherScope);
|
||||
Assert.NotEqual(first, otherFilter);
|
||||
Assert.Equal(
|
||||
first,
|
||||
AppCacheKeys.Statistics(
|
||||
" GRADES ",
|
||||
"COLLEGE",
|
||||
firstCollege,
|
||||
term,
|
||||
null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source()
|
||||
{
|
||||
await using var provider = CreateProvider(enabled: true);
|
||||
var cache = provider.GetRequiredService<IAppCache>();
|
||||
var sourceCalls = 0;
|
||||
var key = $"test:{Guid.NewGuid():N}";
|
||||
|
||||
Task<int> Load(CancellationToken _)
|
||||
{
|
||||
sourceCalls++;
|
||||
return Task.FromResult(sourceCalls);
|
||||
}
|
||||
|
||||
var first = await cache.GetOrCreateAsync(
|
||||
key,
|
||||
Load,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
CancellationToken.None);
|
||||
var second = await cache.GetOrCreateAsync(
|
||||
key,
|
||||
Load,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, first);
|
||||
Assert.Equal(1, second);
|
||||
Assert.Equal(1, sourceCalls);
|
||||
|
||||
await cache.RemoveByTagAsync(AppCacheTags.BaseData);
|
||||
var afterInvalidation = await cache.GetOrCreateAsync(
|
||||
key,
|
||||
Load,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[AppCacheTags.BaseData],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, afterInvalidation);
|
||||
Assert.Equal(2, sourceCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_cache_always_uses_source()
|
||||
{
|
||||
await using var provider = CreateProvider(enabled: false);
|
||||
var cache = provider.GetRequiredService<IAppCache>();
|
||||
var sourceCalls = 0;
|
||||
|
||||
Task<int> Load(CancellationToken _)
|
||||
{
|
||||
sourceCalls++;
|
||||
return Task.FromResult(sourceCalls);
|
||||
}
|
||||
|
||||
var first = await cache.GetOrCreateAsync(
|
||||
"disabled",
|
||||
Load,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[],
|
||||
CancellationToken.None);
|
||||
var second = await cache.GetOrCreateAsync(
|
||||
"disabled",
|
||||
Load,
|
||||
AppCacheProfile.ReferenceData,
|
||||
[],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, first);
|
||||
Assert.Equal(2, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timetable_data_round_trips_through_distributed_cache()
|
||||
{
|
||||
IDistributedCache distributedCache = new SharedDistributedCache(
|
||||
new MemoryDistributedCache(
|
||||
Options.Create(new MemoryDistributedCacheOptions())));
|
||||
var keyPrefix = $"tests:{Guid.NewGuid():N}";
|
||||
await using var writer = CreateProvider(true, distributedCache, keyPrefix);
|
||||
var source = CreateTimetableData();
|
||||
|
||||
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||
"timetable",
|
||||
_ => Task.FromResult(source),
|
||||
AppCacheProfile.PublishedTimetable,
|
||||
[AppCacheTags.Timetables],
|
||||
CancellationToken.None);
|
||||
|
||||
await using var reader = CreateProvider(true, distributedCache, keyPrefix);
|
||||
var sourceCalled = false;
|
||||
var result = await reader.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||
"timetable",
|
||||
_ =>
|
||||
{
|
||||
sourceCalled = true;
|
||||
return Task.FromResult(source);
|
||||
},
|
||||
AppCacheProfile.PublishedTimetable,
|
||||
[AppCacheTags.Timetables],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(sourceCalled);
|
||||
Assert.Equal(source.Term.Id, result.Term.Id);
|
||||
Assert.Equal(source.Plan!.Id, result.Plan!.Id);
|
||||
Assert.Equal("缓存测试课程", result.Entries.Single().CourseName);
|
||||
Assert.Equal(new TimeOnly(8, 45), result.Slots.Single().EndsAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analytics_json_round_trips_through_distributed_cache()
|
||||
{
|
||||
IDistributedCache distributedCache = new SharedDistributedCache(
|
||||
new MemoryDistributedCache(
|
||||
Options.Create(new MemoryDistributedCacheOptions())));
|
||||
var keyPrefix = $"tests:{Guid.NewGuid():N}";
|
||||
var source = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
totals = new { totalCourses = 3 }
|
||||
});
|
||||
|
||||
await using (var writer = CreateProvider(
|
||||
true,
|
||||
distributedCache,
|
||||
keyPrefix))
|
||||
{
|
||||
await writer.GetRequiredService<IAppCache>().GetOrCreateAsync(
|
||||
"statistics:courses",
|
||||
_ => Task.FromResult(source),
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.Analytics],
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var reader = CreateProvider(
|
||||
true,
|
||||
distributedCache,
|
||||
keyPrefix);
|
||||
var sourceCalled = false;
|
||||
var result = await reader.GetRequiredService<IAppCache>()
|
||||
.GetOrCreateAsync(
|
||||
"statistics:courses",
|
||||
_ =>
|
||||
{
|
||||
sourceCalled = true;
|
||||
return Task.FromResult(source);
|
||||
},
|
||||
AppCacheProfile.Analytics,
|
||||
[AppCacheTags.Analytics],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(sourceCalled);
|
||||
Assert.Equal(
|
||||
3,
|
||||
result.GetProperty("totals")
|
||||
.GetProperty("totalCourses")
|
||||
.GetInt32());
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateProvider(
|
||||
bool enabled,
|
||||
IDistributedCache? distributedCache = null,
|
||||
string? keyPrefix = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddHybridCache();
|
||||
if (distributedCache is not null)
|
||||
services.AddSingleton(distributedCache);
|
||||
services.AddSingleton(new AppCacheOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
KeyPrefix = keyPrefix ?? $"tests:{Guid.NewGuid():N}"
|
||||
});
|
||||
services.AddSingleton<IHostEnvironment>(new TestHostEnvironment());
|
||||
services.AddSingleton<IAppCache, HybridAppCache>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static TimetableData CreateTimetableData()
|
||||
{
|
||||
var termId = Guid.NewGuid();
|
||||
var planId = Guid.NewGuid();
|
||||
var taskId = Guid.NewGuid();
|
||||
return new TimetableData(
|
||||
new TimetableTermDto(
|
||||
termId,
|
||||
"2026-2027 学年第一学期",
|
||||
"2026-2027",
|
||||
TermSeason.Autumn,
|
||||
new DateOnly(2026, 9, 1),
|
||||
new DateOnly(2027, 1, 20),
|
||||
true),
|
||||
new TimetableSubjectDto(
|
||||
Guid.NewGuid(),
|
||||
"SE202601",
|
||||
"软件工程 2026 级 1 班",
|
||||
TimetableResourceType.Class,
|
||||
2026,
|
||||
Guid.NewGuid(),
|
||||
"软件工程",
|
||||
Guid.NewGuid(),
|
||||
"计算机学院",
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
null,
|
||||
null,
|
||||
new TimetablePlanDto(
|
||||
planId,
|
||||
"正式课表",
|
||||
"V1",
|
||||
SchedulePlanStatus.Published,
|
||||
DateTime.UtcNow,
|
||||
DateTime.UtcNow),
|
||||
[
|
||||
new TimetableSlotDto(
|
||||
1,
|
||||
"第 1 节",
|
||||
new TimeOnly(8, 0),
|
||||
new TimeOnly(8, 45))
|
||||
],
|
||||
[
|
||||
new TimetableEntryDto(
|
||||
Guid.NewGuid(),
|
||||
taskId,
|
||||
"TASK-001",
|
||||
"缓存测试教学班",
|
||||
"CACHE-01",
|
||||
"缓存测试课程",
|
||||
["测试教师"],
|
||||
["软件工程 2026 级 1 班"],
|
||||
"第一教学楼 101",
|
||||
"第一教学楼",
|
||||
"主校区",
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
16,
|
||||
WeekPattern.All,
|
||||
null)
|
||||
],
|
||||
[],
|
||||
[]);
|
||||
}
|
||||
|
||||
private sealed class TestHostEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = Environments.Development;
|
||||
public string ApplicationName { get; set; } = nameof(AppCacheTests);
|
||||
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
|
||||
}
|
||||
|
||||
private sealed class SharedDistributedCache(IDistributedCache inner)
|
||||
: IDistributedCache
|
||||
{
|
||||
public byte[]? Get(string key) => inner.Get(key);
|
||||
|
||||
public Task<byte[]?> GetAsync(
|
||||
string key,
|
||||
CancellationToken token = default) =>
|
||||
inner.GetAsync(key, token);
|
||||
|
||||
public void Refresh(string key) => inner.Refresh(key);
|
||||
|
||||
public Task RefreshAsync(
|
||||
string key,
|
||||
CancellationToken token = default) =>
|
||||
inner.RefreshAsync(key, token);
|
||||
|
||||
public void Remove(string key) => inner.Remove(key);
|
||||
|
||||
public Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken token = default) =>
|
||||
inner.RemoveAsync(key, token);
|
||||
|
||||
public void Set(
|
||||
string key,
|
||||
byte[] value,
|
||||
DistributedCacheEntryOptions options) =>
|
||||
inner.Set(key, value, options);
|
||||
|
||||
public Task SetAsync(
|
||||
string key,
|
||||
byte[] value,
|
||||
DistributedCacheEntryOptions options,
|
||||
CancellationToken token = default) =>
|
||||
inner.SetAsync(key, value, options, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class ApprovalsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||
{
|
||||
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||
fixture.CurrentTerm.IsCurrent = false;
|
||||
fixture.Db.AcademicTerms.Add(new AcademicTerm
|
||||
{
|
||||
Code = "2026-2",
|
||||
Name = "2026—2027 学年第二学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Spring,
|
||||
StartDate = new DateOnly(2027, 2, 20),
|
||||
EndDate = new DateOnly(2027, 7, 1),
|
||||
IsCurrent = true
|
||||
});
|
||||
fixture.Db.SchedulePlans.Add(new SchedulePlan
|
||||
{
|
||||
AcademicTermId = fixture.CurrentTerm.Id,
|
||||
Name = "正式课表",
|
||||
Version = "v1",
|
||||
Status = SchedulePlanStatus.Published,
|
||||
Entries =
|
||||
[
|
||||
new ScheduleEntry
|
||||
{
|
||||
TeachingTaskId = fixture.CurrentTask.Id,
|
||||
DayOfWeek = 1,
|
||||
StartPeriod = 1,
|
||||
PeriodCount = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
WeekPattern = WeekPattern.All
|
||||
}
|
||||
]
|
||||
});
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.GetMyEnrolledCourses(
|
||||
CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var options = Assert.IsAssignableFrom<
|
||||
IEnumerable<StudentApprovalCourseOption>>(ok.Value).ToList();
|
||||
var option = Assert.Single(options);
|
||||
Assert.Equal(fixture.CurrentTask.Id, option.TeachingTaskId);
|
||||
Assert.Equal(fixture.CurrentCourse.Id, option.CourseId);
|
||||
Assert.True(option.HasPublishedSchedule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exemption_RejectsTeachingTaskOutsideStudentsCurrentCourses()
|
||||
{
|
||||
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||
var unrelatedTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2026-1-OTHER-01",
|
||||
Name = "其他教学班",
|
||||
AcademicTermId = fixture.CurrentTerm.Id,
|
||||
CourseId = fixture.SubstituteCourse.Id,
|
||||
Capacity = 30,
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
fixture.Db.TeachingTasks.Add(unrelatedTask);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.ApplyExemption(
|
||||
new ExemptionRequest(unrelatedTask.Id, "申请原因"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result);
|
||||
Assert.Empty(fixture.Db.CourseExemptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Substitution_AcceptsCurrentCourseAndPublishedPassedCourse()
|
||||
{
|
||||
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||
var historicalTerm = new AcademicTerm
|
||||
{
|
||||
Code = "2025-2",
|
||||
Name = "2025—2026 学年第二学期",
|
||||
AcademicYear = "2025-2026",
|
||||
Season = TermSeason.Spring,
|
||||
StartDate = new DateOnly(2026, 2, 20),
|
||||
EndDate = new DateOnly(2026, 7, 1)
|
||||
};
|
||||
var historicalTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2025-2-PASS-01",
|
||||
Name = "已通过课程教学班",
|
||||
AcademicTermId = historicalTerm.Id,
|
||||
CourseId = fixture.SubstituteCourse.Id,
|
||||
Capacity = 30,
|
||||
Status = TeachingTaskStatus.Closed
|
||||
};
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = historicalTask.Id,
|
||||
Status = GradeSheetStatus.Published,
|
||||
PublishedAt = DateTime.UtcNow.AddDays(-30),
|
||||
Records =
|
||||
[
|
||||
new GradeRecord
|
||||
{
|
||||
StudentId = fixture.Student.Id,
|
||||
TotalScore = 86,
|
||||
GradePoint = 3.6m
|
||||
}
|
||||
]
|
||||
};
|
||||
fixture.Db.AddRange(historicalTerm, historicalTask, sheet);
|
||||
await fixture.Db.SaveChangesAsync();
|
||||
|
||||
var result = await fixture.Controller.ApplySubstitution(
|
||||
new SubstitutionRequest(
|
||||
fixture.CurrentCourse.Id,
|
||||
fixture.SubstituteCourse.Id,
|
||||
"课程内容相近"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<CreatedResult>(result);
|
||||
var substitution = Assert.Single(fixture.Db.CourseSubstitutions);
|
||||
Assert.Equal(fixture.CurrentCourse.Id, substitution.OriginalCourseId);
|
||||
Assert.Equal(
|
||||
fixture.SubstituteCourse.Id,
|
||||
substitution.SubstituteCourseId);
|
||||
}
|
||||
|
||||
private sealed class ApprovalFixture : IAsyncDisposable
|
||||
{
|
||||
private ApprovalFixture(
|
||||
SqliteConnection connection,
|
||||
AppDbContext db,
|
||||
Student student,
|
||||
AcademicTerm currentTerm,
|
||||
Course currentCourse,
|
||||
Course substituteCourse,
|
||||
TeachingTask currentTask,
|
||||
ApprovalsController controller)
|
||||
{
|
||||
Connection = connection;
|
||||
Db = db;
|
||||
Student = student;
|
||||
CurrentTerm = currentTerm;
|
||||
CurrentCourse = currentCourse;
|
||||
SubstituteCourse = substituteCourse;
|
||||
CurrentTask = currentTask;
|
||||
Controller = controller;
|
||||
}
|
||||
|
||||
private SqliteConnection Connection { get; }
|
||||
public AppDbContext Db { get; }
|
||||
public Student Student { get; }
|
||||
public AcademicTerm CurrentTerm { get; }
|
||||
public Course CurrentCourse { get; }
|
||||
public Course SubstituteCourse { get; }
|
||||
public TeachingTask CurrentTask { get; }
|
||||
public ApprovalsController Controller { get; }
|
||||
|
||||
public static async Task<ApprovalFixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "080901",
|
||||
Name = "计算机科学与技术",
|
||||
CollegeId = college.Id,
|
||||
DegreeType = "工学学士"
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "CS2026-01",
|
||||
Name = "计科 2026-1 班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2026
|
||||
};
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = "202601001",
|
||||
NormalizedUserName = "202601001",
|
||||
DisplayName = "测试学生"
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "202601001",
|
||||
Name = "测试学生",
|
||||
UserId = user.Id,
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||
};
|
||||
var currentTerm = new AcademicTerm
|
||||
{
|
||||
Code = "2026-1",
|
||||
Name = "2026—2027 学年第一学期",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 20),
|
||||
IsCurrent = true
|
||||
};
|
||||
var currentCourse = CreateCourse(college.Id, "CS101", "程序设计基础");
|
||||
var substituteCourse = CreateCourse(college.Id, "CS102", "程序设计进阶");
|
||||
var currentTask = new TeachingTask
|
||||
{
|
||||
TaskNumber = "2026-1-CS101-01",
|
||||
Name = "程序设计基础教学班",
|
||||
AcademicTermId = currentTerm.Id,
|
||||
CourseId = currentCourse.Id,
|
||||
Capacity = 60,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Classes =
|
||||
[
|
||||
new TeachingTaskClass
|
||||
{
|
||||
AdministrativeClassId = administrativeClass.Id
|
||||
}
|
||||
]
|
||||
};
|
||||
db.AddRange(
|
||||
college,
|
||||
major,
|
||||
administrativeClass,
|
||||
user,
|
||||
student,
|
||||
currentTerm,
|
||||
currentCourse,
|
||||
substituteCourse,
|
||||
currentTask);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new ApprovalsController(
|
||||
db,
|
||||
new StudentDataScope(user.Id));
|
||||
return new ApprovalFixture(
|
||||
connection,
|
||||
db,
|
||||
student,
|
||||
currentTerm,
|
||||
currentCourse,
|
||||
substituteCourse,
|
||||
currentTask,
|
||||
controller);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Db.DisposeAsync();
|
||||
await Connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private static Course CreateCourse(
|
||||
Guid collegeId,
|
||||
string code,
|
||||
string name) =>
|
||||
new()
|
||||
{
|
||||
Code = code,
|
||||
Name = name,
|
||||
CollegeId = collegeId,
|
||||
Credits = 3,
|
||||
TotalHours = 48,
|
||||
LectureHours = 32,
|
||||
PracticeHours = 16,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"测试学生",
|
||||
null,
|
||||
DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Student]));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -103,7 +104,11 @@ public sealed class AuthControllerTests
|
||||
var userManager = scope.ServiceProvider
|
||||
.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||
var controller = new AuthController(db, userManager, new StubTokenService());
|
||||
var controller = new AuthController(
|
||||
db,
|
||||
userManager,
|
||||
new StubTokenService(),
|
||||
NoOpAppCache.Instance);
|
||||
var request = new StudentActivationRequest(
|
||||
student.Name,
|
||||
student.StudentNumber,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -21,7 +22,7 @@ public sealed class BaseDataControllerTests : IAsyncDisposable
|
||||
.Options;
|
||||
db = new AppDbContext(options);
|
||||
db.Database.EnsureCreated();
|
||||
controller = new BaseDataController(db);
|
||||
controller = new BaseDataController(db, NoOpAppCache.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -61,7 +62,8 @@ public sealed class PersonnelControllerTests
|
||||
var controller = new PersonnelController(
|
||||
db,
|
||||
new TestDataScope(college.Id),
|
||||
userManager);
|
||||
userManager,
|
||||
NoOpAppCache.Instance);
|
||||
|
||||
var result = await controller.ActivateTeacherAccount(
|
||||
teacher.Id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -47,6 +48,7 @@ public sealed class SchedulePublishJobProcessorTests
|
||||
$"Data Source={databasePath};Pooling=False"));
|
||||
services.AddScoped<SchedulePlanPublisher>();
|
||||
services.AddScoped<SchedulePublishJobProcessor>();
|
||||
services.AddSingleton<IAppCache>(NoOpAppCache.Instance);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
try
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
@@ -67,7 +71,7 @@ public sealed class ScheduleSettingsControllerTests
|
||||
secondTask);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = new ScheduleSettingsController(db);
|
||||
var controller = new ScheduleSettingsController(db, NoOpAppCache.Instance);
|
||||
var result = await controller.SaveConstraintsBatch(
|
||||
new TeachingTaskScheduleConstraintBatchRequest(
|
||||
term.Id,
|
||||
@@ -102,6 +106,212 @@ public sealed class ScheduleSettingsControllerTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_constraints_materializes_teachers_and_allowed_classrooms()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var course = new Course
|
||||
{
|
||||
Code = "CS101",
|
||||
Name = "程序设计基础",
|
||||
College = college,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2026-F",
|
||||
Name = "2026 秋季",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 7),
|
||||
EndDate = new DateOnly(2027, 1, 17)
|
||||
};
|
||||
var teacher = new Teacher
|
||||
{
|
||||
TeacherNumber = "T001",
|
||||
Name = "测试教师",
|
||||
College = college
|
||||
};
|
||||
var classroom = new Classroom
|
||||
{
|
||||
Code = "J1-201",
|
||||
Name = "J1-201",
|
||||
Capacity = 80,
|
||||
Building = new Building
|
||||
{
|
||||
Code = "J1",
|
||||
Name = "第一教学楼",
|
||||
Campus = new Campus { Code = "MAIN", Name = "主校区" }
|
||||
}
|
||||
};
|
||||
var task = NewTask("TASK-01", course, term);
|
||||
task.Teachers.Add(new TeachingTaskTeacher
|
||||
{
|
||||
Teacher = teacher,
|
||||
IsPrimary = true
|
||||
});
|
||||
var constraint = new TeachingTaskScheduleConstraint
|
||||
{
|
||||
TeachingTask = task,
|
||||
AllowedDayOfWeeks = "1,3,5",
|
||||
EarliestPeriod = 1,
|
||||
LatestPeriod = 8,
|
||||
AllowedClassrooms =
|
||||
[
|
||||
new TeachingTaskAllowedClassroom { Classroom = classroom }
|
||||
]
|
||||
};
|
||||
db.AddRange(college, course, term, teacher, classroom, task, constraint);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance)
|
||||
.GetConstraints(term.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value);
|
||||
Assert.Single(payload.Cast<object>());
|
||||
var json = JsonSerializer.Serialize(
|
||||
ok.Value,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
Assert.Contains("测试教师", json);
|
||||
Assert.Contains(classroom.Id.ToString(), json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MySql_schedule_settings_materialize_time_slots_and_constraints()
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("JIAOWU_TEST_MYSQL_CONNECTION");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
return;
|
||||
|
||||
var previousConnectionString =
|
||||
Environment.GetEnvironmentVariable("ConnectionStrings__MySql");
|
||||
AppDbContext db;
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ConnectionStrings__MySql",
|
||||
connectionString);
|
||||
db = new AppDbContextFactory().CreateDbContext([]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ConnectionStrings__MySql",
|
||||
previousConnectionString);
|
||||
}
|
||||
await using var ownedDb = db;
|
||||
var term = await db.AcademicTerms.FirstAsync();
|
||||
var course = await db.Courses.FirstAsync();
|
||||
var teacher = await db.Teachers.FirstAsync(
|
||||
item => item.CollegeId == course.CollegeId);
|
||||
var classroom = await db.Classrooms.FirstAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var periodNumber = await db.ScheduleTimeSlots
|
||||
.Where(item => item.AcademicTermId == term.Id)
|
||||
.Select(item => (int?)item.PeriodNumber)
|
||||
.MaxAsync() ?? 0;
|
||||
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = $"MYSQL-{Guid.NewGuid():N}"[..22],
|
||||
Name = "MySQL 排课约束回归",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
WeeklyHours = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
]
|
||||
};
|
||||
db.Add(task);
|
||||
db.Add(new ScheduleTimeSlot
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
PeriodNumber = periodNumber + 1,
|
||||
Name = "MySQL 回归节次",
|
||||
StartsAt = new TimeOnly(8, 0),
|
||||
EndsAt = new TimeOnly(8, 45)
|
||||
});
|
||||
db.Add(new TeachingTaskScheduleConstraint
|
||||
{
|
||||
TeachingTask = task,
|
||||
AllowedDayOfWeeks = "1,3,5",
|
||||
EarliestPeriod = 1,
|
||||
LatestPeriod = 8,
|
||||
AllowedClassrooms =
|
||||
[
|
||||
new TeachingTaskAllowedClassroom { ClassroomId = classroom.Id }
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
var result = await new ScheduleSettingsController(db, NoOpAppCache.Instance)
|
||||
.GetConstraints(term.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value);
|
||||
Assert.Contains(payload.Cast<object>(), item =>
|
||||
JsonSerializer.Serialize(item).Contains(task.TaskNumber));
|
||||
var timeSlotsResult = await new ScheduleSettingsController(
|
||||
db,
|
||||
NoOpAppCache.Instance)
|
||||
.GetTimeSlots(term.Id, CancellationToken.None);
|
||||
var timeSlotsOk = Assert.IsType<OkObjectResult>(timeSlotsResult);
|
||||
var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value);
|
||||
Assert.Contains("08:00", timeSlotsJson);
|
||||
|
||||
var examTimeSlotsResult = await new ExamsController(
|
||||
db,
|
||||
null!,
|
||||
null!,
|
||||
NoOpAppCache.Instance)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var examTimeSlotsOk =
|
||||
Assert.IsType<OkObjectResult>(examTimeSlotsResult);
|
||||
Assert.Contains(
|
||||
"08:00",
|
||||
JsonSerializer.Serialize(examTimeSlotsOk.Value));
|
||||
|
||||
var makeupTimeSlotsResult = await new MakeupExamsController(
|
||||
db,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var makeupTimeSlotsOk =
|
||||
Assert.IsType<OkObjectResult>(makeupTimeSlotsResult);
|
||||
Assert.Contains(
|
||||
"08:00",
|
||||
JsonSerializer.Serialize(makeupTimeSlotsOk.Value));
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
|
||||
private static TeachingTask NewTask(
|
||||
string taskNumber,
|
||||
Course course,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class StatisticsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Course_summary_cache_is_scope_isolated_and_export_is_fresh()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var firstCollege = new College { Code = "C01", Name = "第一学院" };
|
||||
var secondCollege = new College { Code = "C02", Name = "第二学院" };
|
||||
var category = new CourseCategory { Code = "CAT", Name = "测试分类" };
|
||||
db.AddRange(
|
||||
firstCollege,
|
||||
secondCollege,
|
||||
category,
|
||||
CreateCourse("C001", firstCollege, category),
|
||||
CreateCourse("C002", secondCollege, category));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var cache = new RecordingCache();
|
||||
var firstController = new StatisticsController(
|
||||
db,
|
||||
new CollegeDataScope(firstCollege.Id),
|
||||
cache);
|
||||
var secondController = new StatisticsController(
|
||||
db,
|
||||
new CollegeDataScope(secondCollege.Id),
|
||||
cache);
|
||||
|
||||
var first = await firstController.GetCourseSummary(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(1, TotalCourses(first));
|
||||
|
||||
db.Courses.Add(CreateCourse("C003", firstCollege, category));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var cached = await firstController.GetCourseSummary(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(1, TotalCourses(cached));
|
||||
|
||||
var otherCollege = await secondController.GetCourseSummary(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(1, TotalCourses(otherCollege));
|
||||
Assert.Equal(2, cache.SourceCalls);
|
||||
Assert.Equal(2, cache.Keys.Count);
|
||||
|
||||
var export = await firstController.ExportCourses(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
var file = Assert.IsType<FileContentResult>(export);
|
||||
using var stream = new MemoryStream(file.FileContents);
|
||||
using var workbook = new XLWorkbook(stream);
|
||||
Assert.Equal(
|
||||
2,
|
||||
workbook.Worksheet("课程性质").Cell(1, 2).GetValue<int>());
|
||||
Assert.Equal(2, cache.SourceCalls);
|
||||
}
|
||||
|
||||
private static int TotalCourses(ActionResult<object> result)
|
||||
{
|
||||
var json = Assert.IsType<JsonElement>(result.Value);
|
||||
return json.GetProperty("totals").GetProperty("totalCourses").GetInt32();
|
||||
}
|
||||
|
||||
private static Course CreateCourse(
|
||||
string code,
|
||||
College college,
|
||||
CourseCategory category) =>
|
||||
new()
|
||||
{
|
||||
Code = code,
|
||||
Name = $"课程 {code}",
|
||||
CollegeId = college.Id,
|
||||
CourseCategoryId = category.Id,
|
||||
Nature = CourseNature.MajorRequired,
|
||||
Credits = 2,
|
||||
TotalHours = 32,
|
||||
LectureHours = 32,
|
||||
AssessmentMethod = AssessmentMethod.Examination
|
||||
};
|
||||
|
||||
private sealed class RecordingCache : IAppCache
|
||||
{
|
||||
private readonly Dictionary<string, object> values = [];
|
||||
|
||||
public int SourceCalls { get; private set; }
|
||||
public IReadOnlyCollection<string> Keys => values.Keys;
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
AppCacheProfile profile,
|
||||
IReadOnlyCollection<string> tags,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (values.TryGetValue(key, out var value))
|
||||
return (T)value;
|
||||
|
||||
SourceCalls++;
|
||||
var loaded = await factory(cancellationToken);
|
||||
values[key] = loaded!;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public ValueTask RemoveByTagAsync(
|
||||
string tag,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class CollegeDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
Guid.NewGuid(),
|
||||
"学院管理员",
|
||||
collegeId,
|
||||
DataScope.College,
|
||||
new HashSet<string> { SystemRoles.CollegeAdmin });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Caching;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
@@ -146,7 +147,8 @@ public sealed class TeachingTasksControllerTests
|
||||
this.course = course;
|
||||
Controller = new TeachingTasksController(
|
||||
db,
|
||||
dataScope);
|
||||
dataScope,
|
||||
NoOpAppCache.Instance);
|
||||
}
|
||||
|
||||
public AppDbContext Db { get; }
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Close,
|
||||
DocumentChecked,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Switch,
|
||||
} from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface CourseOption {
|
||||
teachingTaskId: string
|
||||
courseId: string
|
||||
taskNumber: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
credits: number
|
||||
academicTermName: string
|
||||
teacherNames: string[]
|
||||
hasPublishedSchedule: boolean
|
||||
schedulingMode: 'Standard' | 'Flexible'
|
||||
}
|
||||
|
||||
interface GradeOption {
|
||||
courseId: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
credits: number
|
||||
totalScore: number | null
|
||||
gradePoint: number | null
|
||||
examStatus: string
|
||||
academicTermName: string
|
||||
taskNumber: string
|
||||
publishedAt: string | null
|
||||
}
|
||||
|
||||
interface SubstitutionTarget {
|
||||
courseId: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
credits: number
|
||||
source: 'assigned' | 'failed'
|
||||
detail: string
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
|
||||
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
|
||||
@@ -11,79 +55,259 @@ const isStudent = computed(() => auth.user?.roles.includes('Student') && !isMana
|
||||
|
||||
const pending = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(isManager.value ? 'pending' : 'mine')
|
||||
const dialog = ref(false)
|
||||
const dialogType = ref('')
|
||||
const courseKeyword = ref('')
|
||||
const originalKeyword = ref('')
|
||||
const substituteKeyword = ref('')
|
||||
const rejectDialog = ref(false)
|
||||
const rejectItem = ref<any>(null)
|
||||
const rejectComment = ref('')
|
||||
|
||||
const enrolledCourses = ref<any[]>([])
|
||||
const enrolledCourses = ref<CourseOption[]>([])
|
||||
const gradeRecords = ref<any[]>([])
|
||||
const myGrades = ref<any[]>([])
|
||||
const myGrades = ref<GradeOption[]>([])
|
||||
const myExemptions = ref<any[]>([])
|
||||
const myDeferred = ref<any[]>([])
|
||||
const mySubstitutions = ref<any[]>([])
|
||||
|
||||
const form = reactive({ teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
|
||||
const form = reactive({
|
||||
teachingTaskId: '',
|
||||
reason: '',
|
||||
originalCourseId: '',
|
||||
substituteCourseId: '',
|
||||
gradeRecordId: '',
|
||||
requestedScore: 0,
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => ({
|
||||
exemption: '发起免修申请',
|
||||
deferred: '发起缓考申请',
|
||||
gradeMod: '成绩修改申请',
|
||||
substitution: '发起课程替代申请',
|
||||
}[dialogType.value] ?? '发起申请'))
|
||||
|
||||
const selectedCourse = computed(() =>
|
||||
enrolledCourses.value.find(course => course.teachingTaskId === form.teachingTaskId))
|
||||
|
||||
const filteredCourses = computed(() => {
|
||||
const keyword = courseKeyword.value.trim().toLocaleLowerCase()
|
||||
if (!keyword) return enrolledCourses.value
|
||||
return enrolledCourses.value.filter(course =>
|
||||
[course.courseCode, course.courseName, course.taskNumber, ...course.teacherNames]
|
||||
.some(value => value?.toLocaleLowerCase().includes(keyword)))
|
||||
})
|
||||
|
||||
function uniqueGrades(predicate: (grade: GradeOption) => boolean) {
|
||||
const result = new Map<string, GradeOption>()
|
||||
for (const grade of myGrades.value) {
|
||||
if (predicate(grade) && !result.has(grade.courseId)) result.set(grade.courseId, grade)
|
||||
}
|
||||
return [...result.values()]
|
||||
}
|
||||
|
||||
const failedGrades = computed(() =>
|
||||
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) < 60))
|
||||
const passedGrades = computed(() =>
|
||||
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) >= 60))
|
||||
|
||||
const substitutionTargets = computed<SubstitutionTarget[]>(() => {
|
||||
const result = new Map<string, SubstitutionTarget>()
|
||||
for (const course of enrolledCourses.value) {
|
||||
result.set(course.courseId, {
|
||||
courseId: course.courseId,
|
||||
courseCode: course.courseCode,
|
||||
courseName: course.courseName,
|
||||
credits: course.credits,
|
||||
source: 'assigned',
|
||||
detail: `${course.academicTermName} · ${course.taskNumber}`,
|
||||
})
|
||||
}
|
||||
for (const grade of failedGrades.value) {
|
||||
if (!result.has(grade.courseId)) {
|
||||
result.set(grade.courseId, {
|
||||
courseId: grade.courseId,
|
||||
courseCode: grade.courseCode,
|
||||
courseName: grade.courseName,
|
||||
credits: grade.credits,
|
||||
source: 'failed',
|
||||
detail: `${grade.academicTermName} · ${grade.totalScore} 分`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...result.values()]
|
||||
})
|
||||
|
||||
function matchesCourse(item: { courseCode: string; courseName: string }, keyword: string) {
|
||||
const normalized = keyword.trim().toLocaleLowerCase()
|
||||
return !normalized ||
|
||||
item.courseCode.toLocaleLowerCase().includes(normalized) ||
|
||||
item.courseName.toLocaleLowerCase().includes(normalized)
|
||||
}
|
||||
|
||||
function selectOriginal(courseId: string) {
|
||||
form.originalCourseId = courseId
|
||||
if (form.substituteCourseId === courseId) form.substituteCourseId = ''
|
||||
}
|
||||
|
||||
const filteredOriginalCourses = computed(() =>
|
||||
substitutionTargets.value.filter(item => matchesCourse(item, originalKeyword.value)))
|
||||
const filteredSubstituteCourses = computed(() =>
|
||||
passedGrades.value.filter(item => matchesCourse(item, substituteKeyword.value)))
|
||||
|
||||
const selectedOriginal = computed(() =>
|
||||
substitutionTargets.value.find(item => item.courseId === form.originalCourseId))
|
||||
const selectedSubstitute = computed(() =>
|
||||
passedGrades.value.find(item => item.courseId === form.substituteCourseId))
|
||||
|
||||
const recordCount = computed(() =>
|
||||
myExemptions.value.length + myDeferred.value.length + mySubstitutions.value.length)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
|
||||
if (isStudent.value) {
|
||||
const [courses, grades, ex, df] = await Promise.all([
|
||||
http.get('/approvals/my-courses'), http.get('/approvals/my-grades'),
|
||||
http.get('/approvals/exemptions/mine'), http.get('/approvals/deferred/mine'),
|
||||
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
||||
http.get('/approvals/my-courses'),
|
||||
http.get('/approvals/my-grades'),
|
||||
http.get('/approvals/exemptions/mine'),
|
||||
http.get('/approvals/deferred/mine'),
|
||||
http.get('/approvals/substitutions/mine'),
|
||||
])
|
||||
enrolledCourses.value = courses.data; myGrades.value = grades.data
|
||||
myExemptions.value = ex.data; myDeferred.value = df.data
|
||||
enrolledCourses.value = courses.data
|
||||
myGrades.value = grades.data
|
||||
myExemptions.value = ex.data
|
||||
myDeferred.value = df.data
|
||||
mySubstitutions.value = substitutions.data
|
||||
}
|
||||
if (isTeacher.value) gradeRecords.value = (await http.get('/approvals/grade-records')).data
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
finally { loading.value = false }
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(type: string) {
|
||||
dialogType.value = type
|
||||
Object.assign(form, { teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
|
||||
courseKeyword.value = ''
|
||||
originalKeyword.value = ''
|
||||
substituteKeyword.value = ''
|
||||
Object.assign(form, {
|
||||
teachingTaskId: '',
|
||||
reason: '',
|
||||
originalCourseId: '',
|
||||
substituteCourseId: '',
|
||||
gradeRecordId: '',
|
||||
requestedScore: 0,
|
||||
})
|
||||
dialog.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!form.reason.trim()) { ElMessage.warning('请填写原因'); return }
|
||||
if (!form.reason.trim()) {
|
||||
ElMessage.warning('请填写申请原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (dialogType.value === 'exemption') {
|
||||
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
|
||||
await http.post('/approvals/exemptions', { teachingTaskId: form.teachingTaskId, reason: form.reason })
|
||||
} else if (dialogType.value === 'deferred') {
|
||||
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
|
||||
await http.post('/approvals/deferred', { teachingTaskId: form.teachingTaskId, reason: form.reason })
|
||||
} else if (dialogType.value === 'gradeMod') {
|
||||
if (!form.gradeRecordId) { ElMessage.warning('请选择成绩记录'); return }
|
||||
await http.post('/approvals/grade-modifications', { gradeRecordId: form.gradeRecordId, requestedScore: form.requestedScore, reason: form.reason })
|
||||
} else if (dialogType.value === 'substitution') {
|
||||
if (!form.originalCourseId || !form.substituteCourseId) { ElMessage.warning('请选择两门课程'); return }
|
||||
await http.post('/approvals/substitutions', { originalCourseId: form.originalCourseId, substituteCourseId: form.substituteCourseId, reason: form.reason })
|
||||
if (!form.teachingTaskId) {
|
||||
ElMessage.warning('请选择申请免修的课程')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/exemptions', {
|
||||
teachingTaskId: form.teachingTaskId,
|
||||
reason: form.reason,
|
||||
})
|
||||
} else if (dialogType.value === 'deferred') {
|
||||
if (!form.teachingTaskId) {
|
||||
ElMessage.warning('请选择申请缓考的课程')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/deferred', {
|
||||
teachingTaskId: form.teachingTaskId,
|
||||
reason: form.reason,
|
||||
})
|
||||
} else if (dialogType.value === 'gradeMod') {
|
||||
if (!form.gradeRecordId) {
|
||||
ElMessage.warning('请选择成绩记录')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/grade-modifications', {
|
||||
gradeRecordId: form.gradeRecordId,
|
||||
requestedScore: form.requestedScore,
|
||||
reason: form.reason,
|
||||
})
|
||||
} else if (dialogType.value === 'substitution') {
|
||||
if (!form.originalCourseId || !form.substituteCourseId) {
|
||||
ElMessage.warning('请完整选择被替代课程和替代课程')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/substitutions', {
|
||||
originalCourseId: form.originalCourseId,
|
||||
substituteCourseId: form.substituteCourseId,
|
||||
reason: form.reason,
|
||||
})
|
||||
}
|
||||
dialog.value = false
|
||||
ElMessage.success('申请已提交')
|
||||
await load()
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
dialog.value = false; ElMessage.success('申请已提交'); await load()
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
}
|
||||
|
||||
async function approveItem(item: any) {
|
||||
try {
|
||||
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
|
||||
if (ep) { await http.post(`${ep}/${item.id}/approve`); ElMessage.success('已通过'); await load() }
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`
|
||||
if (ep) {
|
||||
await http.post(`${ep}/${item.id}/approve`)
|
||||
ElMessage.success('已通过')
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(item: any) { rejectItem.value = item; rejectComment.value = ''; rejectDialog.value = true }
|
||||
function openReject(item: any) {
|
||||
rejectItem.value = item
|
||||
rejectComment.value = ''
|
||||
rejectDialog.value = true
|
||||
}
|
||||
|
||||
async function rejectSubmit() {
|
||||
if (!rejectItem.value) return
|
||||
try {
|
||||
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
|
||||
if (ep) { await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value }); rejectDialog.value = false; ElMessage.success('已驳回'); await load() }
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`
|
||||
if (ep) {
|
||||
await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value })
|
||||
rejectDialog.value = false
|
||||
ElMessage.success('已驳回')
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === 'Submitted' ? '待审核' : status === 'Approved' ? '已通过' : '已驳回'
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
return status === 'Approved' ? 'success' : status === 'Rejected' ? 'danger' : 'warning'
|
||||
}
|
||||
|
||||
function courseScheduleText(course: CourseOption) {
|
||||
if (course.schedulingMode === 'Flexible') return '非排时课程'
|
||||
return course.hasPublishedSchedule ? '课表已发布' : '等待课表发布'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@@ -92,92 +316,419 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="page-stack appr-page">
|
||||
<section class="page-intro">
|
||||
<div><span class="section-kicker">APPROVAL CENTER</span><h2>审批中心</h2><p>学籍异动、调停课、成绩审核、考勤申诉、免修、缓考、成绩修改、课程替代。</p></div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('exemption')">免修</el-button>
|
||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('deferred')">缓考</el-button>
|
||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('substitution')">课程替代</el-button>
|
||||
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<div>
|
||||
<span class="section-kicker">APPROVAL CENTER</span>
|
||||
<h2>审批中心</h2>
|
||||
<p>{{ isStudent ? '从实际修读课程发起申请,查看审核进度与处理结果。' : '集中处理教务申请与审核任务。' }}</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<el-segmented v-model="tab" :options="[
|
||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
||||
{ label: '我的记录', value: 'mine' },
|
||||
]" style="margin-bottom:16px" />
|
||||
<section v-if="isStudent" v-loading="loading" class="application-launcher">
|
||||
<button class="launch-card exemption" type="button" @click="openDialog('exemption')">
|
||||
<span class="launch-icon"><DocumentChecked /></span>
|
||||
<span class="launch-copy">
|
||||
<small>COURSE EXEMPTION</small>
|
||||
<b>申请免修</b>
|
||||
<em>从当前或已发布课表的修读课程中选择</em>
|
||||
</span>
|
||||
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
|
||||
</button>
|
||||
<button class="launch-card deferred" type="button" @click="openDialog('deferred')">
|
||||
<span class="launch-icon"><Clock /></span>
|
||||
<span class="launch-copy">
|
||||
<small>DEFERRED EXAM</small>
|
||||
<b>申请缓考</b>
|
||||
<em>课程与教学班信息一并提交</em>
|
||||
</span>
|
||||
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
|
||||
</button>
|
||||
<button class="launch-card substitution" type="button" @click="openDialog('substitution')">
|
||||
<span class="launch-icon"><Switch /></span>
|
||||
<span class="launch-copy">
|
||||
<small>COURSE SUBSTITUTION</small>
|
||||
<b>申请课程替代</b>
|
||||
<em>修读中或未通过课程 → 已通过课程</em>
|
||||
</span>
|
||||
<span class="launch-meta">{{ passedGrades.length }} 门可替代 <Plus /></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="approval-tabs">
|
||||
<el-segmented
|
||||
v-model="tab"
|
||||
:options="[
|
||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
||||
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
||||
]"
|
||||
/>
|
||||
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Pending -->
|
||||
<section v-if="tab === 'pending'" v-loading="loading" class="appr-list">
|
||||
<article v-for="item in pending" :key="`${item.type}-${item.id}`" class="appr-card">
|
||||
<el-tag size="small" :type="item.label === '考勤申诉' ? 'warning' : 'primary'">{{ item.label }}</el-tag>
|
||||
<div class="appr-body"><b>{{ item.title }}</b><p>{{ item.desc }}</p><small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small></div>
|
||||
<div class="appr-actions" v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)">
|
||||
<div class="appr-body">
|
||||
<b>{{ item.title }}</b>
|
||||
<p>{{ item.desc }}</p>
|
||||
<small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small>
|
||||
</div>
|
||||
<div v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)" class="appr-actions">
|
||||
<el-button size="small" type="danger" plain :icon="Close" @click="openReject(item)">驳回</el-button>
|
||||
<el-button size="small" type="success" :icon="Check" @click="approveItem(item)">通过</el-button>
|
||||
</div>
|
||||
<div class="appr-actions" v-else><el-button size="small" text type="primary" @click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')">查看 →</el-button></div>
|
||||
<div v-else class="appr-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
@click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')"
|
||||
>
|
||||
查看 →
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!pending.length" description="暂无待审批" />
|
||||
</section>
|
||||
|
||||
<!-- My records -->
|
||||
<section v-if="tab === 'mine'" v-loading="loading">
|
||||
<template v-if="isStudent">
|
||||
<h4 style="margin-bottom:8px">免修申请</h4>
|
||||
<div class="appr-list" style="margin-bottom:16px">
|
||||
<article v-for="e in myExemptions" :key="e.id" class="appr-card"><el-tag size="small" :type="e.status === 'Approved' ? 'success' : e.status === 'Rejected' ? 'danger' : 'warning'">{{ e.status === 'Submitted' ? '待审核' : e.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ e.courseName }}</b><p>{{ e.reason }}</p><small v-if="e.reviewComment">{{ e.reviewComment }}</small></div></article>
|
||||
<el-empty v-if="!myExemptions.length" description="无" />
|
||||
<div v-if="recordCount" class="record-columns">
|
||||
<section class="record-group">
|
||||
<header><span class="record-mark exemption"></span><b>免修申请</b><em>{{ myExemptions.length }}</em></header>
|
||||
<div class="appr-list">
|
||||
<article v-for="item in myExemptions" :key="item.id" class="appr-card compact">
|
||||
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||
<div class="appr-body">
|
||||
<b>{{ item.courseName }}</b>
|
||||
<p>{{ item.reason }}</p>
|
||||
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||
</div>
|
||||
<h4 style="margin-bottom:8px">缓考申请</h4>
|
||||
<div class="appr-list" style="margin-bottom:16px">
|
||||
<article v-for="d in myDeferred" :key="d.id" class="appr-card"><el-tag size="small" :type="d.status === 'Approved' ? 'success' : d.status === 'Rejected' ? 'danger' : 'warning'">{{ d.status === 'Submitted' ? '待审核' : d.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ d.courseName }}</b><p>{{ d.reason }}</p></div></article>
|
||||
<el-empty v-if="!myDeferred.length" description="无" />
|
||||
</article>
|
||||
<p v-if="!myExemptions.length" class="record-empty">尚未提交免修申请</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="record-group">
|
||||
<header><span class="record-mark deferred"></span><b>缓考申请</b><em>{{ myDeferred.length }}</em></header>
|
||||
<div class="appr-list">
|
||||
<article v-for="item in myDeferred" :key="item.id" class="appr-card compact">
|
||||
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||
<div class="appr-body">
|
||||
<b>{{ item.courseName }}</b>
|
||||
<p>{{ item.reason }}</p>
|
||||
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!myDeferred.length" class="record-empty">尚未提交缓考申请</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="record-group substitution-records">
|
||||
<header><span class="record-mark substitution"></span><b>课程替代申请</b><em>{{ mySubstitutions.length }}</em></header>
|
||||
<div class="appr-list">
|
||||
<article v-for="item in mySubstitutions" :key="item.id" class="appr-card compact">
|
||||
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||
<div class="appr-body">
|
||||
<b>{{ item.substituteCourseCode }} {{ item.substituteCourseName }} → {{ item.originalCourseCode }} {{ item.originalCourseName }}</b>
|
||||
<p>{{ item.reason }}</p>
|
||||
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!mySubstitutions.length" class="record-empty">尚未提交课程替代申请</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="还没有申请记录">
|
||||
<el-button type="primary" @click="openDialog('exemption')">发起第一项申请</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialog" :title="dialogType === 'exemption' ? '免修申请' : dialogType === 'deferred' ? '缓考申请' : dialogType === 'gradeMod' ? '成绩修改申请' : '课程替代申请'" width="560px">
|
||||
<el-dialog v-model="dialog" :title="dialogTitle" width="min(780px, calc(100vw - 24px))" class="application-dialog" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<template v-if="dialogType === 'exemption' || dialogType === 'deferred'">
|
||||
<el-form-item label="选择课程" required>
|
||||
<el-select v-model="form.teachingTaskId" filterable><el-option v-for="c in enrolledCourses" :key="c.teachingTaskId" :label="`${c.courseCode} ${c.courseName}`" :value="c.teachingTaskId" /></el-select>
|
||||
<div class="dialog-lead">
|
||||
<span :class="['dialog-lead-icon', dialogType]">
|
||||
<DocumentChecked v-if="dialogType === 'exemption'" />
|
||||
<Clock v-else />
|
||||
</span>
|
||||
<div>
|
||||
<b>{{ dialogType === 'exemption' ? '选择申请免修的教学班' : '选择申请缓考的教学班' }}</b>
|
||||
<p>列表包含当前学期课程,以及仍未归档且课表已发布的行政班或已选课程。</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="课程" required>
|
||||
<el-input v-model="courseKeyword" :prefix-icon="Search" clearable placeholder="搜索课程名称、代码、教学班号或教师" />
|
||||
<div v-if="filteredCourses.length" class="course-picker">
|
||||
<button
|
||||
v-for="course in filteredCourses"
|
||||
:key="course.teachingTaskId"
|
||||
type="button"
|
||||
:class="['course-choice', { selected: form.teachingTaskId === course.teachingTaskId }]"
|
||||
@click="form.teachingTaskId = course.teachingTaskId"
|
||||
>
|
||||
<span class="course-code">{{ course.courseCode }}</span>
|
||||
<span class="course-main">
|
||||
<b>{{ course.courseName }}</b>
|
||||
<small>{{ course.taskNumber }} · {{ course.academicTermName }}</small>
|
||||
<em>{{ course.teacherNames.length ? course.teacherNames.join('、') : '教师待定' }} · {{ course.credits }} 学分</em>
|
||||
</span>
|
||||
<span :class="['schedule-state', { ready: course.hasPublishedSchedule || course.schedulingMode === 'Flexible' }]">
|
||||
{{ courseScheduleText(course) }}
|
||||
</span>
|
||||
<span class="choice-check"><Check /></span>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else
|
||||
:description="enrolledCourses.length ? '没有匹配的课程' : '暂无可申请课程'"
|
||||
:image-size="76"
|
||||
>
|
||||
<p v-if="!enrolledCourses.length" class="empty-explanation">
|
||||
课程需为已发布教学任务,并分配到您的行政班或已完成选课;当前学期课程或已发布课表的课程均会显示。
|
||||
</p>
|
||||
</el-empty>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="dialogType === 'substitution'">
|
||||
<el-form-item label="未通过课程(被替代)" required>
|
||||
<el-select v-model="form.originalCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) < 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="已通过课程(替代课程)" required>
|
||||
<el-select v-model="form.substituteCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) >= 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
title="替代关系须同时满足两个条件"
|
||||
description="被替代课程应为当前或已发布课表的修读课程,或已有未通过成绩的课程;替代课程必须已有正式发布且不低于 60 分的成绩。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="substitution-flow">
|
||||
<section class="substitution-column">
|
||||
<header><span>1</span><div><b>被替代课程</b><small>修读课程 / 历史未通过</small></div></header>
|
||||
<el-input v-model="originalKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
|
||||
<div v-if="filteredOriginalCourses.length" class="mini-course-list">
|
||||
<button
|
||||
v-for="course in filteredOriginalCourses"
|
||||
:key="course.courseId"
|
||||
type="button"
|
||||
:class="{ selected: form.originalCourseId === course.courseId }"
|
||||
@click="selectOriginal(course.courseId)"
|
||||
>
|
||||
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.detail }} · {{ course.credits }} 学分</small></span>
|
||||
<el-tag size="small" :type="course.source === 'assigned' ? 'primary' : 'danger'">
|
||||
{{ course.source === 'assigned' ? '修读' : '未通过' }}
|
||||
</el-tag>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty v-else :image-size="62" description="暂无可被替代课程" />
|
||||
</section>
|
||||
<div class="flow-arrow">→</div>
|
||||
<section class="substitution-column">
|
||||
<header><span>2</span><div><b>替代课程</b><small>已发布且成绩及格</small></div></header>
|
||||
<el-input v-model="substituteKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
|
||||
<div v-if="filteredSubstituteCourses.length" class="mini-course-list">
|
||||
<button
|
||||
v-for="course in filteredSubstituteCourses"
|
||||
:key="course.courseId"
|
||||
type="button"
|
||||
:disabled="course.courseId === form.originalCourseId"
|
||||
:class="{ selected: form.substituteCourseId === course.courseId }"
|
||||
@click="form.substituteCourseId = course.courseId"
|
||||
>
|
||||
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.academicTermName }} · {{ course.credits }} 学分</small></span>
|
||||
<strong>{{ course.totalScore }}<small>分</small></strong>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty v-else :image-size="62" description="暂无已通过课程">
|
||||
<p class="empty-explanation">已排课但尚未发布成绩的课程不属于可替代课程。</p>
|
||||
</el-empty>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="dialogType === 'gradeMod'">
|
||||
<el-form-item label="成绩记录" required>
|
||||
<el-select v-model="form.gradeRecordId" filterable><el-option v-for="r in gradeRecords" :key="r.id" :label="`${r.studentName}(${r.studentNumber}) — ${r.courseName} — ${r.totalScore}分`" :value="r.id" /></el-select>
|
||||
<el-select v-model="form.gradeRecordId" filterable placeholder="选择已发布成绩">
|
||||
<el-option
|
||||
v-for="record in gradeRecords"
|
||||
:key="record.id"
|
||||
:label="`${record.studentName}(${record.studentNumber}) — ${record.courseName} — ${record.totalScore}分`"
|
||||
:value="record.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="修改为" required>
|
||||
<el-input-number v-model="form.requestedScore" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="修改为" required><el-input-number v-model="form.requestedScore" :min="0" :max="100" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="原因" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
|
||||
<el-form-item class="reason-field" label="申请原因" required>
|
||||
<el-input
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:placeholder="dialogType === 'deferred' ? '请说明无法按时参加考试的原因及相关情况' : dialogType === 'substitution' ? '请说明两门课程在内容、学分或培养要求上的对应关系' : '请说明申请依据及相关情况'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="submitForm">提交申请</el-button></template>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<span v-if="selectedCourse">{{ selectedCourse.courseCode }} · {{ selectedCourse.courseName }}</span>
|
||||
<span v-else-if="selectedOriginal || selectedSubstitute">
|
||||
{{ selectedSubstitute?.courseName ?? '请选择替代课程' }} → {{ selectedOriginal?.courseName ?? '请选择被替代课程' }}
|
||||
</span>
|
||||
<el-button @click="dialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">提交申请</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Reject -->
|
||||
<el-dialog v-model="rejectDialog" title="驳回" width="460px">
|
||||
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="驳回原因" />
|
||||
<template #footer><el-button @click="rejectDialog = false">取消</el-button><el-button type="danger" @click="rejectSubmit">确认驳回</el-button></template>
|
||||
<el-dialog v-model="rejectDialog" title="驳回申请" width="min(460px, calc(100vw - 24px))">
|
||||
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写驳回原因" />
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialog = false">取消</el-button>
|
||||
<el-button type="danger" @click="rejectSubmit">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.application-launcher {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.launch-card {
|
||||
position: relative;
|
||||
min-height: 132px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
color: #1e293b;
|
||||
background: linear-gradient(145deg, #fff 48%, #f6f8fb);
|
||||
border: 1px solid #dfe4eb;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.launch-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -26px;
|
||||
bottom: -50px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 18px solid rgba(67, 97, 238, .05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.launch-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #aebbd0;
|
||||
box-shadow: 0 12px 28px rgba(30, 41, 59, .08);
|
||||
}
|
||||
|
||||
.launch-icon, .dialog-lead-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #3556b5;
|
||||
background: #edf2ff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.launch-icon svg, .dialog-lead-icon svg { width: 21px; }
|
||||
.deferred .launch-icon, .dialog-lead-icon.deferred { color: #a85d00; background: #fff3dc; }
|
||||
.substitution .launch-icon { color: #087f5b; background: #e8f8f1; }
|
||||
.launch-copy { display: grid; gap: 3px; min-width: 0; }
|
||||
.launch-copy small { color: #8791a3; font: 600 9px/1.2 ui-monospace, monospace; letter-spacing: .1em; }
|
||||
.launch-copy b { margin-top: 3px; font-size: 17px; }
|
||||
.launch-copy em { color: #667085; font-size: 11px; font-style: normal; line-height: 1.5; }
|
||||
.launch-meta { position: absolute; left: 76px; bottom: 17px; display: flex; align-items: center; gap: 5px; color: #506079; font-size: 10px; }
|
||||
.launch-meta svg { width: 12px; }
|
||||
|
||||
.approval-tabs { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.appr-list { display: grid; gap: 8px; }
|
||||
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; }
|
||||
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 10px; }
|
||||
.appr-card.compact { align-items: flex-start; padding: 13px; }
|
||||
.appr-body { flex: 1; min-width: 0; }
|
||||
.appr-body b { font-size: 14px; display: block; margin-bottom: 4px; }
|
||||
.appr-body p { font-size: 13px; color: #606266; margin: 0 0 4px; }
|
||||
.appr-body small { font-size: 11px; color: var(--muted); }
|
||||
.appr-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.appr-body b { display: block; margin-bottom: 4px; font-size: 13px; }
|
||||
.appr-body p { margin: 0 0 4px; color: #606266; font-size: 12px; line-height: 1.55; }
|
||||
.appr-body small { color: var(--muted); font-size: 10px; }
|
||||
.appr-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
|
||||
.record-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||
.record-group { padding: 16px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
|
||||
.record-group.substitution-records { grid-column: 1 / -1; }
|
||||
.record-group > header { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.record-group > header b { font-size: 13px; }
|
||||
.record-group > header em { margin-left: auto; min-width: 22px; padding: 3px 7px; color: #667085; background: #fff; border-radius: 10px; font-size: 10px; font-style: normal; text-align: center; }
|
||||
.record-mark { width: 8px; height: 8px; background: #4b6fd8; border-radius: 50%; box-shadow: 0 0 0 4px #e8edfb; }
|
||||
.record-mark.deferred { background: #d5841c; box-shadow: 0 0 0 4px #fff0d8; }
|
||||
.record-mark.substitution { background: #13916a; box-shadow: 0 0 0 4px #dff5ed; }
|
||||
.record-empty { margin: 20px 0; color: #98a2b3; font-size: 11px; text-align: center; }
|
||||
|
||||
.dialog-lead { margin-bottom: 18px; padding: 14px; display: flex; align-items: center; gap: 12px; background: #f7f9fc; border: 1px solid #e6eaf1; border-radius: 10px; }
|
||||
.dialog-lead b { display: block; margin-bottom: 3px; font-size: 13px; }
|
||||
.dialog-lead p { margin: 0; color: #667085; font-size: 11px; }
|
||||
.course-picker { width: 100%; max-height: 330px; margin-top: 10px; display: grid; gap: 8px; overflow: auto; }
|
||||
.course-choice { width: 100%; padding: 13px; display: grid; grid-template-columns: 68px minmax(0, 1fr) auto 20px; align-items: center; gap: 12px; text-align: left; background: #fff; border: 1px solid #e1e6ee; border-radius: 10px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||
.course-choice:hover { border-color: #9aabd4; }
|
||||
.course-choice.selected { background: #f4f7ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
|
||||
.course-code { color: #3651a3; font: 700 11px/1.3 ui-monospace, monospace; }
|
||||
.course-main { display: grid; min-width: 0; gap: 3px; }
|
||||
.course-main b { color: #1e293b; font-size: 13px; }
|
||||
.course-main small, .course-main em { overflow: hidden; color: #7b8495; font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.schedule-state { padding: 4px 7px; color: #8a6116; background: #fff4d9; border-radius: 6px; font-size: 9px; }
|
||||
.schedule-state.ready { color: #087f5b; background: #e5f7ef; }
|
||||
.choice-check { display: grid; place-items: center; width: 18px; height: 18px; color: transparent; border: 1px solid #cfd5df; border-radius: 50%; }
|
||||
.choice-check svg { width: 11px; }
|
||||
.course-choice.selected .choice-check { color: #fff; background: #4b6fd8; border-color: #4b6fd8; }
|
||||
.empty-explanation { max-width: 450px; margin: -10px auto 0; color: #8992a3; font-size: 10px; line-height: 1.6; }
|
||||
|
||||
.substitution-flow { margin-top: 16px; display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); align-items: stretch; gap: 10px; }
|
||||
.substitution-column { min-width: 0; padding: 14px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
|
||||
.substitution-column > header { margin-bottom: 12px; display: flex; align-items: center; gap: 9px; }
|
||||
.substitution-column > header > span { width: 25px; height: 25px; display: grid; place-items: center; color: #fff; background: #405fb7; border-radius: 8px; font: 700 11px/1 ui-monospace, monospace; }
|
||||
.substitution-column > header div { display: grid; gap: 2px; }
|
||||
.substitution-column > header b { font-size: 12px; }
|
||||
.substitution-column > header small { color: #8a94a5; font-size: 9px; }
|
||||
.flow-arrow { display: grid; place-items: center; color: #7d8fbf; font-size: 18px; }
|
||||
.mini-course-list { max-height: 255px; margin-top: 9px; display: grid; align-content: start; gap: 6px; overflow: auto; }
|
||||
.mini-course-list button { padding: 10px; display: flex; align-items: center; gap: 8px; text-align: left; background: #fff; border: 1px solid #e2e7ee; border-radius: 8px; cursor: pointer; }
|
||||
.mini-course-list button:hover { border-color: #a9b5d3; }
|
||||
.mini-course-list button.selected { background: #f0f4ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
|
||||
.mini-course-list button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.mini-course-list button > span { min-width: 0; display: grid; flex: 1; gap: 3px; }
|
||||
.mini-course-list button b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mini-course-list button span small { color: #8a94a5; font-size: 9px; }
|
||||
.mini-course-list button > strong { color: #087f5b; font-size: 16px; }
|
||||
.mini-course-list button > strong small { margin-left: 1px; font-size: 8px; font-weight: 500; }
|
||||
.reason-field { margin-top: 18px; }
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.dialog-footer > span { margin-right: auto; overflow: hidden; color: #667085; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.application-launcher { grid-template-columns: 1fr; }
|
||||
.launch-card { min-height: 112px; }
|
||||
.record-columns { grid-template-columns: 1fr; }
|
||||
.record-group.substitution-records { grid-column: auto; }
|
||||
.substitution-flow { grid-template-columns: 1fr; }
|
||||
.flow-arrow { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-intro { align-items: flex-start; }
|
||||
.approval-tabs { align-items: flex-start; }
|
||||
.course-choice { grid-template-columns: 58px minmax(0, 1fr) 18px; }
|
||||
.schedule-state { grid-column: 2; justify-self: start; }
|
||||
.appr-card { align-items: flex-start; flex-wrap: wrap; }
|
||||
.appr-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user