diff --git a/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs new file mode 100644 index 0000000..864bc75 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/CourseAdjustmentsController.cs @@ -0,0 +1,440 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/course-adjustments")] +public sealed class CourseAdjustmentsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope) : ControllerBase +{ + private const string Applicants = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin + "," + + SystemRoles.Teacher; + + private const string Reviewers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + + // ═══════════════ My adjustments ═══════════════ + + [HttpGet("mine")] + [Authorize(Roles = Applicants)] + public async Task GetMine( + Guid? academicTermId, + CourseAdjustmentStatus? status, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var source = db.CourseAdjustments.AsNoTracking() + .Where(x => x.ApplicantUserId == userId); + if (academicTermId.HasValue) + source = source.Where(x => + x.TeachingTask!.AcademicTermId == academicTermId); + if (status.HasValue) + source = source.Where(x => x.Status == status); + + return Ok(await source + .OrderByDescending(x => x.CreatedAt) + .Select(AdjustmentProjection()) + .ToListAsync(cancellationToken)); + } + + // ═══════════════ Pending reviews ═══════════════ + + [HttpGet("pending-reviews")] + [Authorize(Roles = Reviewers)] + public async Task GetPendingReviews( + Guid? academicTermId, + CancellationToken cancellationToken) + { + var scope = currentUserDataScope.Current; + var source = db.CourseAdjustments.AsNoTracking() + .Where(x => x.Status == CourseAdjustmentStatus.Submitted); + if (scope.Scope == DataScope.College) + source = source.Where(x => + x.TeachingTask!.Course!.CollegeId == scope.RestrictedCollegeId); + if (academicTermId.HasValue) + source = source.Where(x => + x.TeachingTask!.AcademicTermId == academicTermId); + + return Ok(await source + .OrderByDescending(x => x.SubmittedAt) + .Select(AdjustmentProjection()) + .ToListAsync(cancellationToken)); + } + + // ═══════════════ Detail ═══════════════ + + [HttpGet("{id:guid}")] + [Authorize(Roles = Applicants)] + public async Task GetDetail(Guid id, CancellationToken cancellationToken) + { + var adj = await db.CourseAdjustments.AsNoTracking() + .Where(x => x.Id == id) + .Select(AdjustmentProjection()) + .FirstOrDefaultAsync(cancellationToken); + return adj is null ? NotFound() : Ok(adj); + } + + // ═══════════════ Create ═══════════════ + + [HttpPost] + [Authorize(Roles = Applicants)] + public async Task Create( + CourseAdjustmentRequest request, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var validation = await ValidateRequestAsync(request, cancellationToken); + if (validation is not null) return validation; + + var adj = new CourseAdjustment + { + TeachingTaskId = request.TeachingTaskId, + Type = request.Type, + ApplicantUserId = userId, + Reason = request.Reason.Trim(), + TargetDate = request.TargetDate, + DayOfWeek = request.DayOfWeek, + StartPeriod = request.StartPeriod, + PeriodCount = request.PeriodCount, + ClassroomId = request.ClassroomId, + SubstituteTeacherId = request.SubstituteTeacherId, + CancelWeek = request.CancelWeek, + CancelDate = request.CancelDate + }; + + if (request.Submit) + { + adj.Status = CourseAdjustmentStatus.Submitted; + adj.SubmittedAt = DateTime.UtcNow; + } + + db.CourseAdjustments.Add(adj); + await db.SaveChangesAsync(cancellationToken); + + if (request.Submit) + await NotifyReviewersAsync(adj, cancellationToken); + + return Created(string.Empty, new { adj.Id }); + } + + // ═══════════════ Submit ═══════════════ + + [HttpPost("{id:guid}/submit")] + [Authorize(Roles = Applicants)] + public async Task Submit(Guid id, CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var adj = await db.CourseAdjustments + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync(x => + x.Id == id && x.ApplicantUserId == userId, cancellationToken); + if (adj is null) return NotFound(); + if (adj.Status != CourseAdjustmentStatus.Draft) + return ConflictProblem("只有草稿可以提交。"); + + adj.Status = CourseAdjustmentStatus.Submitted; + adj.SubmittedAt = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + await NotifyReviewersAsync(adj, cancellationToken); + return NoContent(); + } + + // ═══════════════ Approve ═══════════════ + + [HttpPost("{id:guid}/approve")] + [Authorize(Roles = Reviewers)] + public async Task Approve(Guid id, CancellationToken cancellationToken) + { + var adj = await db.CourseAdjustments + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (adj is null) return NotFound(); + + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.College && + adj.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId) + return ConflictProblem("只能审核本学院课程的调停课申请。"); + if (adj.Status != CourseAdjustmentStatus.Submitted) + return ConflictProblem("只有待审核的申请可以审批。"); + + adj.Status = CourseAdjustmentStatus.Approved; + adj.ReviewedAt = DateTime.UtcNow; + adj.ReviewedByUserId = currentUserDataScope.Current.UserId; + await db.SaveChangesAsync(cancellationToken); + await NotifyApplicantAsync(adj, "已通过", cancellationToken); + return NoContent(); + } + + // ═══════════════ Reject ═══════════════ + + [HttpPost("{id:guid}/reject")] + [Authorize(Roles = Reviewers)] + public async Task Reject( + Guid id, + RejectionRequest request, + CancellationToken cancellationToken) + { + var adj = await db.CourseAdjustments + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (adj is null) return NotFound(); + + var scope = currentUserDataScope.Current; + if (scope.Scope == DataScope.College && + adj.TeachingTask!.Course!.CollegeId != scope.RestrictedCollegeId) + return ConflictProblem("只能审核本学院课程的调停课申请。"); + if (adj.Status != CourseAdjustmentStatus.Submitted) + return ConflictProblem("只有待审核的申请可以退回。"); + + adj.Status = CourseAdjustmentStatus.Rejected; + adj.ReviewComment = request.Comment?.Trim(); + adj.ReviewedAt = DateTime.UtcNow; + adj.ReviewedByUserId = currentUserDataScope.Current.UserId; + await db.SaveChangesAsync(cancellationToken); + await NotifyApplicantAsync(adj, + $"已退回" + (adj.ReviewComment is not null ? $":{adj.ReviewComment}" : ""), + cancellationToken); + return NoContent(); + } + + // ═══════════════ Notifications ═══════════════ + + [HttpGet("notifications")] + [Authorize] + public async Task GetNotifications( + bool? unreadOnly, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var source = db.Notifications.AsNoTracking() + .Where(x => x.UserId == userId); + if (unreadOnly == true) + source = source.Where(x => !x.IsRead); + + return Ok(new + { + Items = await source.OrderByDescending(x => x.CreatedAt) + .Take(50) + .Select(x => new + { + x.Id, x.Title, x.Content, x.IsRead, x.LinkUrl, x.CreatedAt + }) + .ToListAsync(cancellationToken), + UnreadCount = await db.Notifications + .CountAsync(x => x.UserId == userId && !x.IsRead, cancellationToken) + }); + } + + [HttpPost("notifications/{id:guid}/read")] + [Authorize] + public async Task MarkRead(Guid id, CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var n = await db.Notifications + .FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken); + if (n is null) return NotFound(); + n.IsRead = true; + await db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPost("notifications/read-all")] + [Authorize] + public async Task MarkAllRead(CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + await db.Notifications + .Where(x => x.UserId == userId && !x.IsRead) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsRead, true), + cancellationToken); + return NoContent(); + } + + // ═══════════════ Helpers ═══════════════ + + private async Task NotifyReviewersAsync( + CourseAdjustment adj, CancellationToken ct) + { + var typeLabel = adj.Type switch + { + CourseAdjustmentType.Reschedule => "调课", + CourseAdjustmentType.Cancel => "停课", + CourseAdjustmentType.Makeup => "补课", + CourseAdjustmentType.Substitute => "代课", + _ => "调停课" + }; + var taskInfo = adj.TeachingTask is not null + ? $"{adj.TeachingTask.Course!.Name}({adj.TeachingTask.TaskNumber})" + : ""; + + // Notify CollegeAdmin and AcademicAdmin of the course's college + var collegeId = adj.TeachingTask?.Course?.CollegeId; + if (!collegeId.HasValue) return; + + var reviewerUserIds = await db.Users + .Join(db.UserRoles, u => u.Id, ur => ur.UserId, (u, ur) => new { u.Id, ur.RoleId }) + .Join(db.Roles, x => x.RoleId, r => r.Id, (x, r) => new { x.Id, RoleName = r.Name! }) + .Where(x => + (x.RoleName == SystemRoles.AcademicAdmin) || + (x.RoleName == SystemRoles.CollegeAdmin && + db.Teachers.Any(t => + t.UserId == x.Id && t.CollegeId == collegeId))) + .Select(x => x.Id) + .Distinct() + .ToListAsync(ct); + + foreach (var reviewerId in reviewerUserIds) + { + db.Notifications.Add(new Notification + { + UserId = reviewerId, + Title = $"新的{typeLabel}申请", + Content = $"{taskInfo} 提交了{typeLabel}申请,请及时审核。", + LinkUrl = "/course-adjustments" + }); + } + await db.SaveChangesAsync(ct); + } + + private async Task NotifyApplicantAsync( + CourseAdjustment adj, string result, CancellationToken ct) + { + var typeLabel = adj.Type switch + { + CourseAdjustmentType.Reschedule => "调课", + CourseAdjustmentType.Cancel => "停课", + CourseAdjustmentType.Makeup => "补课", + CourseAdjustmentType.Substitute => "代课", + _ => "调停课" + }; + db.Notifications.Add(new Notification + { + UserId = adj.ApplicantUserId, + Title = $"{typeLabel}申请{result}", + Content = adj.ReviewComment is not null + ? $"您的{typeLabel}申请{result}。审核意见:{adj.ReviewComment}" + : $"您的{typeLabel}申请{result}。", + LinkUrl = "/course-adjustments" + }); + await db.SaveChangesAsync(ct); + } + + private async Task ValidateRequestAsync( + CourseAdjustmentRequest request, + CancellationToken cancellationToken) + { + var task = await db.TeachingTasks.AsNoTracking() + .Include(x => x.Course) + .FirstOrDefaultAsync(x => + x.Id == request.TeachingTaskId && + x.Status == TeachingTaskStatus.Published, + cancellationToken); + if (task is null) return ValidationProblem("教学班不存在或未发布。"); + + switch (request.Type) + { + case CourseAdjustmentType.Reschedule: + case CourseAdjustmentType.Makeup: + if (!request.DayOfWeek.HasValue || request.DayOfWeek is < 1 or > 7) + return ValidationProblem("请选择有效的上课日。"); + if (!request.StartPeriod.HasValue || request.StartPeriod < 1) + return ValidationProblem("请选择起始节次。"); + if (!request.PeriodCount.HasValue || request.PeriodCount < 1) + return ValidationProblem("请选择持续节数。"); + if (request.Type == CourseAdjustmentType.Makeup && + !request.TargetDate.HasValue) + return ValidationProblem("补课必须指定日期。"); + break; + case CourseAdjustmentType.Substitute: + if (!request.SubstituteTeacherId.HasValue) + return ValidationProblem("请选择代课教师。"); + if (!await db.Teachers.AnyAsync(x => + x.Id == request.SubstituteTeacherId && + x.Status == TeacherStatus.Active, cancellationToken)) + return ValidationProblem("代课教师不存在或已离职。"); + break; + case CourseAdjustmentType.Cancel: + if (!request.CancelWeek.HasValue && !request.CancelDate.HasValue) + return ValidationProblem("停课需指定周次或日期。"); + break; + } + + return null; + } + + private static System.Linq.Expressions.Expression< + Func> AdjustmentProjection() => x => new + { + x.Id, + x.TeachingTaskId, + x.TeachingTask!.TaskNumber, + TaskName = x.TeachingTask.Name, + CourseCode = x.TeachingTask.Course!.Code, + CourseName = x.TeachingTask.Course.Name, + CollegeName = x.TeachingTask.Course.College!.Name, + TeacherNames = x.TeachingTask.Teachers + .OrderByDescending(t => t.IsPrimary) + .Select(t => t.Teacher!.Name), + x.Type, + x.Status, + x.Reason, + x.ReviewComment, + x.TargetDate, + x.DayOfWeek, + x.StartPeriod, + x.PeriodCount, + ClassroomName = x.Classroom != null ? x.Classroom.Name : null, + BuildingName = x.Classroom != null ? x.Classroom.Building!.Name : null, + SubstituteTeacherName = x.SubstituteTeacher != null ? x.SubstituteTeacher.Name : null, + x.CancelWeek, + x.CancelDate, + x.ApplicantUserId, + x.SubmittedAt, + x.ReviewedAt, + x.CreatedAt + }; + + private ActionResult ConflictProblem(string detail) => + Conflict(new ProblemDetails + { + Title = "无法完成操作", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); +} + +// ═══════════════ Request records ═══════════════ + +public sealed record CourseAdjustmentRequest( + Guid TeachingTaskId, + CourseAdjustmentType Type, + [Required, MaxLength(500)] string Reason, + bool Submit, + DateOnly? TargetDate, + [Range(1, 7)] int? DayOfWeek, + [Range(1, 30)] int? StartPeriod, + [Range(1, 6)] int? PeriodCount, + Guid? ClassroomId, + Guid? SubstituteTeacherId, + int? CancelWeek, + DateOnly? CancelDate); + +public sealed record RejectionRequest( + [MaxLength(500)] string? Comment); diff --git a/src/Jiaowu.Api/Domain/Academic/CourseAdjustmentEntities.cs b/src/Jiaowu.Api/Domain/Academic/CourseAdjustmentEntities.cs new file mode 100644 index 0000000..0c90baa --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/CourseAdjustmentEntities.cs @@ -0,0 +1,50 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class CourseAdjustment : EntityBase +{ + public Guid TeachingTaskId { get; set; } + public TeachingTask? TeachingTask { get; set; } + public CourseAdjustmentType Type { get; set; } + public CourseAdjustmentStatus Status { get; set; } = CourseAdjustmentStatus.Draft; + public Guid ApplicantUserId { get; set; } + + // For Reschedule / Makeup + public DateOnly? TargetDate { get; set; } + public int? DayOfWeek { get; set; } + public int? StartPeriod { get; set; } + public int? PeriodCount { get; set; } + public Guid? ClassroomId { get; set; } + public Classroom? Classroom { get; set; } + + // For Substitute + public Guid? SubstituteTeacherId { get; set; } + public Teacher? SubstituteTeacher { get; set; } + + // For Cancel + public int? CancelWeek { get; set; } + public DateOnly? CancelDate { get; set; } + + public string Reason { get; set; } = ""; + public string? ReviewComment { get; set; } + public DateTime? SubmittedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + public Guid? ReviewedByUserId { get; set; } +} + +public enum CourseAdjustmentType +{ + Reschedule = 1, + Cancel = 2, + Makeup = 3, + Substitute = 4 +} + +public enum CourseAdjustmentStatus +{ + Draft = 1, + Submitted = 2, + Approved = 3, + Rejected = 4 +} diff --git a/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs b/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs new file mode 100644 index 0000000..57ce40f --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/NotificationEntities.cs @@ -0,0 +1,12 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class Notification : EntityBase +{ + public Guid UserId { get; set; } + public required string Title { get; set; } + public required string Content { get; set; } + public bool IsRead { get; set; } + public string? LinkUrl { get; set; } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 58fb2ce..44b77f4 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -55,6 +55,8 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet ExamSessions => Set(); public DbSet ExamSessionInvigilators => Set(); + public DbSet CourseAdjustments => Set(); + public DbSet Notifications => Set(); public DbSet StudentStatusChanges => Set(); public DbSet GraduationAuditBatches => @@ -668,6 +670,30 @@ public sealed class AppDbContext(DbContextOptions options) .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); }); + builder.Entity(entity => + { + entity.Property(x => x.Reason).HasMaxLength(500); + entity.Property(x => x.ReviewComment).HasMaxLength(500); + entity.HasIndex(x => new { x.TeachingTaskId, x.Status }); + entity.HasIndex(x => x.ApplicantUserId); + entity.HasIndex(x => new { x.Status, x.CreatedAt }); + entity.HasOne(x => x.TeachingTask).WithMany() + .HasForeignKey(x => x.TeachingTaskId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.Classroom).WithMany() + .HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(x => x.SubstituteTeacher).WithMany() + .HasForeignKey(x => x.SubstituteTeacherId).OnDelete(DeleteBehavior.SetNull); + }); + + builder.Entity(entity => + { + entity.Property(x => x.Title).HasMaxLength(200); + entity.Property(x => x.Content).HasMaxLength(1000); + entity.Property(x => x.LinkUrl).HasMaxLength(300); + entity.HasIndex(x => new { x.UserId, x.IsRead }); + entity.HasIndex(x => x.CreatedAt); + }); + builder.Entity(entity => { entity.Property(x => x.Method).HasMaxLength(10); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index e1ee4cc..6b46f6c 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -36,6 +36,8 @@ public sealed class DevelopmentSqliteMigrator( "20260725_20_exam_scheduling_optimization"; private const string RetakeEnrollmentMigration = "20260725_21_retake_enrollment"; + private const string CourseAdjustmentsMigration = + "20260725_22_course_adjustments"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -240,6 +242,19 @@ public sealed class DevelopmentSqliteMigrator( RetakeEnrollmentMigration, retakeEnrollmentExists ? [] : RetakeEnrollmentStatements, cancellationToken); + + var courseAdjustmentsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'CourseAdjustments' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + CourseAdjustmentsMigration, + courseAdjustmentsExist ? [] : CourseAdjustmentsStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1484,4 +1499,58 @@ public sealed class DevelopmentSqliteMigrator( ADD COLUMN "EnrollmentType" INTEGER NOT NULL DEFAULT 1; """ ]; + + private static readonly string[] CourseAdjustmentsStatements = + [ + """ + CREATE TABLE "CourseAdjustments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_CourseAdjustments" PRIMARY KEY, + "TeachingTaskId" TEXT NOT NULL, + "Type" INTEGER NOT NULL, + "Status" INTEGER NOT NULL, + "ApplicantUserId" TEXT NOT NULL, + "TargetDate" TEXT NULL, + "DayOfWeek" INTEGER NULL, + "StartPeriod" INTEGER NULL, + "PeriodCount" INTEGER NULL, + "ClassroomId" TEXT NULL, + "SubstituteTeacherId" TEXT NULL, + "CancelWeek" INTEGER NULL, + "CancelDate" TEXT NULL, + "Reason" TEXT NOT NULL, + "ReviewComment" TEXT NULL, + "SubmittedAt" TEXT NULL, + "ReviewedAt" TEXT NULL, + "ReviewedByUserId" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_CourseAdjustments_TeachingTasks" + FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") + ON DELETE RESTRICT, + CONSTRAINT "FK_CourseAdjustments_Classrooms" + FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") + ON DELETE SET NULL, + CONSTRAINT "FK_CourseAdjustments_Teachers" + FOREIGN KEY ("SubstituteTeacherId") REFERENCES "Teachers" ("Id") + ON DELETE SET NULL + ); + """, + """CREATE INDEX "IX_CourseAdjustments_TeachingTaskId_Status" ON "CourseAdjustments" ("TeachingTaskId", "Status");""", + """CREATE INDEX "IX_CourseAdjustments_ApplicantUserId" ON "CourseAdjustments" ("ApplicantUserId");""", + """CREATE INDEX "IX_CourseAdjustments_Status_CreatedAt" ON "CourseAdjustments" ("Status", "CreatedAt");""", + """ + CREATE TABLE "Notifications" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_Notifications" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "Title" TEXT NOT NULL, + "Content" TEXT NOT NULL, + "IsRead" INTEGER NOT NULL, + "LinkUrl" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """, + """CREATE INDEX "IX_Notifications_UserId_IsRead" ON "Notifications" ("UserId", "IsRead");""", + """CREATE INDEX "IX_Notifications_CreatedAt" ON "Notifications" ("CreatedAt");""" + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725160000_CourseAdjustments.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725160000_CourseAdjustments.cs new file mode 100644 index 0000000..34a1e26 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260725160000_CourseAdjustments.cs @@ -0,0 +1,125 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class CourseAdjustments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CourseAdjustments", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + TeachingTaskId = table.Column(type: "char(36)", nullable: false), + Type = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + ApplicantUserId = table.Column(type: "char(36)", nullable: false), + TargetDate = table.Column(type: "date", nullable: true), + DayOfWeek = table.Column(type: "int", nullable: true), + StartPeriod = table.Column(type: "int", nullable: true), + PeriodCount = table.Column(type: "int", nullable: true), + ClassroomId = table.Column(type: "char(36)", nullable: true), + SubstituteTeacherId = table.Column(type: "char(36)", nullable: true), + CancelWeek = table.Column(type: "int", nullable: true), + CancelDate = table.Column(type: "date", nullable: true), + Reason = table.Column(type: "varchar(500)", maxLength: 500, nullable: false), + ReviewComment = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + SubmittedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedAt = table.Column(type: "datetime(6)", nullable: true), + ReviewedByUserId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CourseAdjustments", x => x.Id); + table.ForeignKey( + name: "FK_CourseAdjustments_Classrooms_ClassroomId", + column: x => x.ClassroomId, + principalTable: "Classrooms", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CourseAdjustments_Teachers_SubstituteTeacherId", + column: x => x.SubstituteTeacherId, + principalTable: "Teachers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_CourseAdjustments_TeachingTasks_TeachingTaskId", + column: x => x.TeachingTaskId, + principalTable: "TeachingTasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + UserId = table.Column(type: "char(36)", nullable: false), + Title = table.Column(type: "varchar(200)", maxLength: 200, nullable: false), + Content = table.Column(type: "varchar(1000)", maxLength: 1000, nullable: false), + IsRead = table.Column(type: "tinyint(1)", nullable: false), + LinkUrl = table.Column(type: "varchar(300)", maxLength: 300, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_CourseAdjustments_ApplicantUserId", + table: "CourseAdjustments", + column: "ApplicantUserId"); + + migrationBuilder.CreateIndex( + name: "IX_CourseAdjustments_ClassroomId", + table: "CourseAdjustments", + column: "ClassroomId"); + + migrationBuilder.CreateIndex( + name: "IX_CourseAdjustments_Status_CreatedAt", + table: "CourseAdjustments", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_CourseAdjustments_SubstituteTeacherId", + table: "CourseAdjustments", + column: "SubstituteTeacherId"); + + migrationBuilder.CreateIndex( + name: "IX_CourseAdjustments_TeachingTaskId_Status", + table: "CourseAdjustments", + columns: new[] { "TeachingTaskId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_CreatedAt", + table: "Notifications", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_UserId_IsRead", + table: "Notifications", + columns: new[] { "UserId", "IsRead" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "Notifications"); + migrationBuilder.DropTable(name: "CourseAdjustments"); + } + } +} diff --git a/web/src/components.d.ts b/web/src/components.d.ts index ed54c3c..c608d88 100644 --- a/web/src/components.d.ts +++ b/web/src/components.d.ts @@ -12,6 +12,7 @@ export {} declare module 'vue' { export interface GlobalComponents { ElAlert: typeof import('element-plus/es')['ElAlert'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 5fb4430..f1bd1bc 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -130,6 +130,13 @@ const navigationGroups = computed(() => [ label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理', }, ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']), + { + path: '/course-adjustments', + label: isTeacher.value ? '我的调停课' : '调停课审核', + }, + ), ], }, { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index b54d0bd..5f6bd02 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -189,6 +189,14 @@ const router = createRouter({ component: () => import('../views/ExamsView.vue'), meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] }, }, + { + path: 'course-adjustments', + name: 'course-adjustments', + component: () => import('../views/CourseAdjustmentsView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'], + }, + }, { path: 'student-status-changes', name: 'student-status-changes', diff --git a/web/src/views/CourseAdjustmentsView.vue b/web/src/views/CourseAdjustmentsView.vue new file mode 100644 index 0000000..6db3b4e --- /dev/null +++ b/web/src/views/CourseAdjustmentsView.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/web/src/views/CourseSelectionView.vue b/web/src/views/CourseSelectionView.vue index aa272fb..0f51b2b 100644 --- a/web/src/views/CourseSelectionView.vue +++ b/web/src/views/CourseSelectionView.vue @@ -596,6 +596,7 @@ async function loadForceEligibleStudents(page = eligiblePage.value) { keyword: studentKeyword.value.trim() || undefined, page, pageSize: 20, + forceMode: true, }, }, ) @@ -1148,7 +1149,8 @@ onMounted(async () => { >代选学生 强制选课