调课、停课、补课申请
This commit is contained in:
@@ -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<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||
Set<ExamSessionInvigilator>();
|
||||
public DbSet<CourseAdjustment> CourseAdjustments => Set<CourseAdjustment>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<StudentStatusChange> StudentStatusChanges =>
|
||||
Set<StudentStatusChange>();
|
||||
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
|
||||
@@ -668,6 +670,30 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.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 =>
|
||||
{
|
||||
entity.Property(x => x.Method).HasMaxLength(10);
|
||||
|
||||
@@ -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<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(
|
||||
@@ -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");"""
|
||||
];
|
||||
}
|
||||
|
||||
+125
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -130,6 +130,13 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理',
|
||||
},
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
|
||||
{
|
||||
path: '/course-adjustments',
|
||||
label: isTeacher.value ? '我的调停课' : '调停课审核',
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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 () => {
|
||||
>代选学生</el-button>
|
||||
<el-button
|
||||
v-if="isManager"
|
||||
type="warning"
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Plus"
|
||||
@click="openForceEnrollment"
|
||||
>强制选课</el-button>
|
||||
|
||||
Reference in New Issue
Block a user