调课、停课、补课申请

This commit is contained in:
2026-07-25 15:36:14 +08:00 Unverified
parent 9685435da9
commit 13b61f191a
11 changed files with 1162 additions and 1 deletions
@@ -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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult?> 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<CourseAdjustment, object>> 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);
@@ -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
}
@@ -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; }
}
@@ -55,6 +55,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
Set<ExamSessionInvigilator>(); Set<ExamSessionInvigilator>();
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<StudentStatusChange> StudentStatusChanges => public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>(); Set<StudentStatusChange>();
public DbSet<GraduationAuditBatch> GraduationAuditBatches => public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
@@ -668,6 +670,30 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<CourseAdjustment>(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<Notification>(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<AuditLog>(entity => builder.Entity<AuditLog>(entity =>
{ {
entity.Property(x => x.Method).HasMaxLength(10); entity.Property(x => x.Method).HasMaxLength(10);
@@ -36,6 +36,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_20_exam_scheduling_optimization"; "20260725_20_exam_scheduling_optimization";
private const string RetakeEnrollmentMigration = private const string RetakeEnrollmentMigration =
"20260725_21_retake_enrollment"; "20260725_21_retake_enrollment";
private const string CourseAdjustmentsMigration =
"20260725_22_course_adjustments";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -240,6 +242,19 @@ public sealed class DevelopmentSqliteMigrator(
RetakeEnrollmentMigration, RetakeEnrollmentMigration,
retakeEnrollmentExists ? [] : RetakeEnrollmentStatements, retakeEnrollmentExists ? [] : RetakeEnrollmentStatements,
cancellationToken); cancellationToken);
var courseAdjustmentsExist = await db.Database
.SqlQueryRaw<int>(
"""
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( private async Task ApplyMigrationAsync(
@@ -1484,4 +1499,58 @@ public sealed class DevelopmentSqliteMigrator(
ADD COLUMN "EnrollmentType" INTEGER NOT NULL DEFAULT 1; 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");"""
];
} }
@@ -0,0 +1,125 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class CourseAdjustments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CourseAdjustments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ApplicantUserId = table.Column<Guid>(type: "char(36)", nullable: false),
TargetDate = table.Column<DateOnly>(type: "date", nullable: true),
DayOfWeek = table.Column<int>(type: "int", nullable: true),
StartPeriod = table.Column<int>(type: "int", nullable: true),
PeriodCount = table.Column<int>(type: "int", nullable: true),
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: true),
SubstituteTeacherId = table.Column<Guid>(type: "char(36)", nullable: true),
CancelWeek = table.Column<int>(type: "int", nullable: true),
CancelDate = table.Column<DateOnly>(type: "date", nullable: true),
Reason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "char(36)", nullable: false),
UserId = table.Column<Guid>(type: "char(36)", nullable: false),
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
IsRead = table.Column<bool>(type: "tinyint(1)", nullable: false),
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(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" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Notifications");
migrationBuilder.DropTable(name: "CourseAdjustments");
}
}
}
+1
View File
@@ -12,6 +12,7 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+7
View File
@@ -130,6 +130,13 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理', label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理',
}, },
), ),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
{
path: '/course-adjustments',
label: isTeacher.value ? '我的调停课' : '调停课审核',
},
),
], ],
}, },
{ {
+8
View File
@@ -189,6 +189,14 @@ const router = createRouter({
component: () => import('../views/ExamsView.vue'), component: () => import('../views/ExamsView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student'] }, 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', path: 'student-status-changes',
name: 'student-status-changes', name: 'student-status-changes',
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
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)
const adjustments = ref<any[]>([])
const pendingReviews = ref<any[]>([])
const notifications = ref<any[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const reviewLoading = ref(false)
const dialog = ref(false)
const rejectDialog = ref(false)
const currentAdj = ref<any>(null)
const rejectComment = ref('')
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const termId = ref<string>()
const tab = ref(isManager.value ? 'reviews' : 'mine')
const form = reactive({
teachingTaskId: undefined as string | undefined,
type: 'Reschedule' as string,
reason: '',
submit: true,
targetDate: '' as string,
dayOfWeek: undefined as number | undefined,
startPeriod: undefined as number | undefined,
periodCount: 2,
classroomId: undefined as string | undefined,
substituteTeacherId: undefined as string | undefined,
cancelWeek: undefined as number | undefined,
cancelDate: '' as string,
})
const typeLabels: Record<string, string> = { Reschedule: '调课', Cancel: '停课', Makeup: '补课', Substitute: '代课' }
const statusLabels: Record<string, string> = { Draft: '草稿', Submitted: '待审核', Approved: '已通过', Rejected: '已退回' }
const statusColors: Record<string, string> = { Draft: 'info', Submitted: 'warning', Approved: 'success', Rejected: 'danger' }
function periodOptions() {
return Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}` }))
}
async function load() {
loading.value = true
try {
if (isTeacher.value || (!isManager.value)) {
adjustments.value = (await http.get('/course-adjustments/mine', {
params: { academicTermId: termId.value },
})).data
}
if (isManager.value) {
pendingReviews.value = (await http.get('/course-adjustments/pending-reviews', {
params: { academicTermId: termId.value },
})).data
}
await loadNotifications()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
async function loadNotifications() {
try {
const { data } = await http.get('/course-adjustments/notifications')
notifications.value = data.items
unreadCount.value = data.unreadCount
} catch (_) { /* notifications are optional */ }
}
function openCreate() {
Object.assign(form, {
teachingTaskId: undefined,
type: 'Reschedule',
reason: '',
submit: true,
targetDate: '',
dayOfWeek: undefined,
startPeriod: undefined,
periodCount: 2,
classroomId: undefined,
substituteTeacherId: undefined,
cancelWeek: undefined,
cancelDate: '',
})
dialog.value = true
}
async function submit() {
if (!form.teachingTaskId) { ElMessage.warning('请选择教学班'); return }
if (!form.reason.trim()) { ElMessage.warning('请填写申请原因'); return }
try {
await http.post('/course-adjustments', {
...form,
dayOfWeek: form.dayOfWeek || null,
startPeriod: form.startPeriod || null,
classroomId: form.classroomId || null,
substituteTeacherId: form.substituteTeacherId || null,
cancelWeek: form.cancelWeek || null,
targetDate: form.targetDate || null,
cancelDate: form.cancelDate || null,
})
dialog.value = false
ElMessage.success('申请已提交')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function doSubmit(adj: any) {
try {
await http.post(`/course-adjustments/${adj.id}/submit`)
ElMessage.success('已提交审核')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function doApprove(adj: any) {
try {
await http.post(`/course-adjustments/${adj.id}/approve`)
ElMessage.success('已通过')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
function openReject(adj: any) {
currentAdj.value = adj
rejectComment.value = ''
rejectDialog.value = true
}
async function doReject() {
if (!currentAdj.value) return
try {
await http.post(`/course-adjustments/${currentAdj.value.id}/reject`, {
comment: rejectComment.value,
})
rejectDialog.value = false
ElMessage.success('已退回')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
async function markRead(n: any) {
if (n.isRead) return
try {
await http.post(`/course-adjustments/notifications/${n.id}/read`)
n.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (_) {}
}
async function markAllRead() {
try {
await http.post('/course-adjustments/notifications/read-all')
notifications.value.forEach(n => n.isRead = true)
unreadCount.value = 0
} catch (_) {}
}
onMounted(async () => {
try {
const [termRes, taskRes] = await Promise.all([
http.get('/base-data/terms'),
isTeacher.value || !isManager.value
? http.get('/teaching-tasks', { params: { page: 1, pageSize: 100 } })
: Promise.resolve({ data: { items: [] } }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
termId.value = terms.value.find(t => t.isCurrent)?.id
} catch (_) {}
await load()
})
function showTypeFields(type: string) {
return type === 'Reschedule' || type === 'Makeup'
}
function showDate(type: string) { return type === 'Makeup' }
function showSub(type: string) { return type === 'Substitute' }
function showCancel(type: string) { return type === 'Cancel' }
</script>
<template>
<div class="page-stack adj-page">
<section class="page-intro">
<div>
<span class="section-kicker">COURSE ADJUSTMENT</span>
<h2>调停课管理</h2>
<p>教师提交调课停课补课代课申请学院审核后生效并通知相关人员</p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<el-badge :value="unreadCount" :hidden="!unreadCount">
<el-button :icon="Bell" @click="tab = 'notifications'">消息</el-button>
</el-badge>
<el-button v-if="isTeacher" type="primary" :icon="Plus" @click="openCreate">提交申请</el-button>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</div>
</section>
<section class="adj-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
<el-segmented v-model="tab" :options="[
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
...(isManager ? [{ label: '待审核', value: 'reviews' }] : []),
{ label: '消息通知', value: 'notifications' },
]" />
</section>
<!-- My applications -->
<section v-if="tab === 'mine'" v-loading="loading" class="adj-list">
<article v-for="adj in adjustments" :key="adj.id" class="adj-card" :class="adj.status.toLowerCase()">
<header>
<div>
<span>{{ adj.courseCode }} · {{ adj.taskNumber }}</span>
<h3>{{ adj.courseName }}</h3>
<p>{{ adj.teacherNames.join('、') }} · {{ adj.collegeName }}</p>
</div>
<div class="adj-tags">
<el-tag :type="adj.type === 'Reschedule' ? 'primary' : adj.type === 'Cancel' ? 'danger' : adj.type === 'Makeup' ? 'success' : 'warning'" size="small">
{{ typeLabels[adj.type] }}
</el-tag>
<el-tag :type="statusColors[adj.status]" size="small">{{ statusLabels[adj.status] }}</el-tag>
</div>
</header>
<div class="adj-body">
<p v-if="adj.reason"><b>原因</b>{{ adj.reason }}</p>
<div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div>
<el-alert v-if="adj.reviewComment" :title="`审核意见:${adj.reviewComment}`" type="warning" :closable="false" show-icon />
</div>
<footer v-if="adj.status === 'Draft'">
<el-button size="small" type="primary" @click="doSubmit(adj)">提交审核</el-button>
</footer>
</article>
<el-empty v-if="!adjustments.length" description="暂无调停课申请" />
</section>
<!-- Pending reviews -->
<section v-if="tab === 'reviews'" v-loading="reviewLoading" class="adj-list">
<article v-for="adj in pendingReviews" :key="adj.id" class="adj-card review">
<header>
<div>
<span>{{ adj.courseCode }} · {{ adj.taskNumber }}</span>
<h3>{{ adj.courseName }}</h3>
<p>{{ adj.teacherNames.join('、') }} · {{ adj.collegeName }}</p>
</div>
<el-tag :type="adj.type === 'Reschedule' ? 'primary' : adj.type === 'Cancel' ? 'danger' : adj.type === 'Makeup' ? 'success' : 'warning'" size="small">
{{ typeLabels[adj.type] }}
</el-tag>
</header>
<div class="adj-body">
<p><b>原因</b>{{ adj.reason }}</p>
<div class="adj-details">
<span v-if="adj.targetDate"><b>日期</b>{{ adj.targetDate }}</span>
<span v-if="adj.dayOfWeek"><b>星期</b>{{ ['','一','二','三','四','五','六','日'][adj.dayOfWeek] }}</span>
<span v-if="adj.startPeriod"><b>节次</b>{{ adj.startPeriod }}-{{ adj.startPeriod + adj.periodCount - 1 }}</span>
<span v-if="adj.classroomName"><b>教室</b>{{ adj.buildingName }} {{ adj.classroomName }}</span>
<span v-if="adj.substituteTeacherName"><b>代课教师</b>{{ adj.substituteTeacherName }}</span>
<span v-if="adj.cancelWeek"><b>停课周次</b>{{ adj.cancelWeek }}</span>
<span v-if="adj.cancelDate"><b>停课日期</b>{{ adj.cancelDate }}</span>
</div>
<p class="adj-meta"><b>提交时间</b>{{ new Date(adj.submittedAt).toLocaleString('zh-CN') }}</p>
</div>
<footer>
<el-button size="small" type="danger" plain @click="openReject(adj)">退回</el-button>
<el-button size="small" type="success" :icon="Check" @click="doApprove(adj)">通过</el-button>
</footer>
</article>
<el-empty v-if="!pendingReviews.length" description="暂无待审核申请" />
</section>
<!-- Notifications -->
<section v-if="tab === 'notifications'" class="adj-list">
<div v-if="unreadCount" class="notif-actions">
<el-button size="small" text @click="markAllRead">全部标为已读</el-button>
</div>
<article v-for="n in notifications" :key="n.id" class="notif-card" :class="{ unread: !n.isRead }" @click="markRead(n)">
<div class="notif-dot" v-if="!n.isRead" />
<div>
<b>{{ n.title }}</b>
<p>{{ n.content }}</p>
<small>{{ new Date(n.createdAt).toLocaleString('zh-CN') }}</small>
</div>
</article>
<el-empty v-if="!notifications.length" description="暂无消息通知" />
</section>
<!-- Create dialog -->
<el-dialog v-model="dialog" title="提交调停课申请" width="680px" top="5vh">
<el-form label-position="top">
<el-form-item label="教学班" required>
<el-select v-model="form.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="t in tasks" :key="t.id" :label="`${t.taskNumber} · ${t.courseName}`" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="申请类型" required>
<el-segmented v-model="form.type" :options="[
{ label: '调课', value: 'Reschedule' },
{ label: '停课', value: 'Cancel' },
{ label: '补课', value: 'Makeup' },
{ label: '代课', value: 'Substitute' },
]" />
</el-form-item>
<template v-if="showDate(form.type)">
<el-form-item label="补课日期" required>
<el-date-picker v-model="form.targetDate" type="date" value-format="YYYY-MM-DD" placeholder="选择补课日期" />
</el-form-item>
</template>
<template v-if="showTypeFields(form.type)">
<div class="form-grid three">
<el-form-item label="星期">
<el-select v-model="form.dayOfWeek" placeholder="选择">
<el-option v-for="d in 7" :key="d" :label="['','周一','周二','周三','周四','周五','周六','周日'][d]" :value="d" />
</el-select>
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="form.startPeriod">
<el-option v-for="o in periodOptions()" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-input-number v-model="form.periodCount" :min="1" :max="6" />
</el-form-item>
</div>
<el-form-item label="教室(可选)">
<el-select v-model="form.classroomId" clearable filterable placeholder="留空不调整教室">
<el-option v-for="r in []" :key="r" /> <!-- classrooms loaded separately if needed -->
</el-select>
</el-form-item>
</template>
<template v-if="showSub(form.type)">
<el-form-item label="代课教师" required>
<el-select v-model="form.substituteTeacherId" filterable placeholder="选择代课教师">
<el-option v-for="t in []" :key="t" /> <!-- teachers loaded separately if needed -->
</el-select>
</el-form-item>
</template>
<template v-if="showCancel(form.type)">
<div class="form-grid two">
<el-form-item label="停课周次">
<el-input-number v-model="form.cancelWeek" :min="1" :max="20" placeholder="第几周" />
</el-form-item>
<el-form-item label="停课日期">
<el-date-picker v-model="form.cancelDate" type="date" value-format="YYYY-MM-DD" placeholder="或选择日期" />
</el-form-item>
</div>
</template>
<el-form-item label="申请原因" required>
<el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请详细说明调停课原因" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.submit">提交后直接送审</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" @click="submit">提交申请</el-button>
</template>
</el-dialog>
<!-- Reject dialog -->
<el-dialog v-model="rejectDialog" title="退回申请" width="500px">
<el-form label-position="top">
<el-form-item label="退回意见">
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请说明退回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialog = false">取消</el-button>
<el-button type="danger" @click="doReject">确认退回</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.adj-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.adj-list { display: grid; gap: 12px; }
.adj-card { background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; padding: 16px 20px; }
.adj-card header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.adj-card header h3 { font-size: 15px; margin: 4px 0; }
.adj-card header p { font-size: 12px; color: var(--muted); margin: 0; }
.adj-card header span { font-size: 11px; color: var(--muted); text-transform: uppercase; }
.adj-tags { display: flex; gap: 6px; flex-shrink: 0; }
.adj-body { margin: 12px 0; font-size: 13px; }
.adj-details { display: flex; flex-wrap: wrap; gap: 4px 16px; color: var(--muted); font-size: 12px; margin-top: 8px; }
.adj-meta { font-size: 11px; color: var(--muted); margin-top: 8px; }
.adj-card footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid #ebeef5; padding-top: 12px; }
.adj-card.review { border-left: 4px solid #e6a23c; }
.adj-card.draft { border-left: 4px solid #909399; }
.adj-card.submitted { border-left: 4px solid #e6a23c; }
.adj-card.approved { border-left: 4px solid #67c23a; }
.adj-card.rejected { border-left: 4px solid #f56c6c; }
.notif-card { display: flex; align-items: flex-start; gap: 12px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; cursor: pointer; }
.notif-card.unread { background: #ecf5ff; border-color: #b3d8ff; }
.notif-dot { width: 8px; height: 8px; border-radius: 50%; background: #409eff; flex-shrink: 0; margin-top: 6px; }
.notif-card b { font-size: 14px; display: block; margin-bottom: 4px; }
.notif-card p { font-size: 13px; color: #606266; margin: 0; }
.notif-card small { font-size: 11px; color: var(--muted); }
.notif-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; }
</style>
+3 -1
View File
@@ -596,6 +596,7 @@ async function loadForceEligibleStudents(page = eligiblePage.value) {
keyword: studentKeyword.value.trim() || undefined, keyword: studentKeyword.value.trim() || undefined,
page, page,
pageSize: 20, pageSize: 20,
forceMode: true,
}, },
}, },
) )
@@ -1148,7 +1149,8 @@ onMounted(async () => {
>代选学生</el-button> >代选学生</el-button>
<el-button <el-button
v-if="isManager" v-if="isManager"
type="warning" type="danger"
plain
:icon="Plus" :icon="Plus"
@click="openForceEnrollment" @click="openForceEnrollment"
>强制选课</el-button> >强制选课</el-button>