免修、缓考现在包含当前学期课程,以及未归档且课表已发布的实际修读课程。
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 27m29s
Build and publish Jiaowu packages and container image / Test, package and publish (push) Successful in 27m29s
实际开发库验证已返回 CS101 程序设计基础。 课程替代改为“修读中/未通过课程 → 已发布及格课程”的双栏选择。 新增搜索、教学班号、学期、教师、学分、课表状态和完整空状态说明。 新增课程替代申请记录。 提交接口增加服务端资格校验,不能伪造课程 ID 绕过前端。
This commit is contained in:
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
{
|
{
|
||||||
var studentId = await GetStudentIdAsync(ct);
|
var studentId = await GetStudentIdAsync(ct);
|
||||||
if (studentId is null) return StudentNotFound();
|
if (studentId is null) return StudentNotFound();
|
||||||
|
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||||
|
studentId.Value, req.TeachingTaskId, ct))
|
||||||
|
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||||
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有免修申请在审核中。");
|
return ConflictProblem("该课程已有免修申请在审核中。");
|
||||||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||||
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
|
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||||
|
sid.Value, req.TeachingTaskId, ct))
|
||||||
|
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||||
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||||||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||||
@@ -248,12 +254,75 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Course Substitution ═══════════════
|
// ═══════════════ Course Substitution ═══════════════
|
||||||
|
[HttpGet("substitutions/mine")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sid = await GetStudentIdAsync(ct);
|
||||||
|
if (sid is null) return StudentNotFound();
|
||||||
|
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||||||
|
.Where(x => x.StudentId == sid)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.Select(x => new StudentCourseSubstitutionRecord(
|
||||||
|
x.Id,
|
||||||
|
x.Status,
|
||||||
|
x.Reason,
|
||||||
|
x.ReviewComment,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.OriginalCourse!.Code,
|
||||||
|
x.OriginalCourse.Name,
|
||||||
|
x.SubstituteCourse!.Code,
|
||||||
|
x.SubstituteCourse.Name))
|
||||||
|
.ToListAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("substitutions")]
|
[HttpPost("substitutions")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
|
if (req.OriginalCourseId == req.SubstituteCourseId)
|
||||||
|
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||||||
|
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.AnyAsync(task =>
|
||||||
|
task.CourseId == req.OriginalCourseId &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
!task.AcademicTerm!.IsArchived &&
|
||||||
|
(task.AcademicTerm.IsCurrent ||
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == task.Id &&
|
||||||
|
entry.SchedulePlan!.Status ==
|
||||||
|
SchedulePlanStatus.Published)) &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.Id == sid.Value)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == sid.Value &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
task.Id)),
|
||||||
|
ct);
|
||||||
|
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||||||
|
.AnyAsync(record =>
|
||||||
|
record.StudentId == sid.Value &&
|
||||||
|
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
record.GradeSheet.TeachingTask!.CourseId ==
|
||||||
|
req.OriginalCourseId &&
|
||||||
|
record.TotalScore < 60,
|
||||||
|
ct);
|
||||||
|
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||||||
|
if (!canReplaceOriginal)
|
||||||
|
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||||||
|
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||||||
|
.AnyAsync(x =>
|
||||||
|
x.StudentId == sid.Value &&
|
||||||
|
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||||
|
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||||||
|
x.TotalScore >= 60,
|
||||||
|
ct);
|
||||||
|
if (!hasPassedSubstitute)
|
||||||
|
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||||||
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||||||
return ConflictProblem("该课程已有替代申请在审核中。");
|
return ConflictProblem("该课程已有替代申请在审核中。");
|
||||||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
||||||
@@ -319,16 +388,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════ Student enrolled courses ═══════════════
|
// ═══════════════ Student course options ═══════════════
|
||||||
[HttpGet("my-courses")]
|
[HttpGet("my-courses")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var sid = await GetStudentIdAsync(ct);
|
var sid = await GetStudentIdAsync(ct);
|
||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
var courses = await db.CourseEnrollments.AsNoTracking()
|
var courses = await db.TeachingTasks.AsNoTracking()
|
||||||
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
|
.Where(task =>
|
||||||
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
!task.AcademicTerm!.IsArchived &&
|
||||||
|
(task.AcademicTerm.IsCurrent ||
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == task.Id &&
|
||||||
|
entry.SchedulePlan!.Status ==
|
||||||
|
SchedulePlanStatus.Published)) &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.Id == sid.Value)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == sid.Value &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
task.Id)))
|
||||||
|
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||||
|
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.Course!.Code)
|
||||||
|
.ThenBy(x => x.TaskNumber)
|
||||||
|
.Select(x => new StudentApprovalCourseOption(
|
||||||
|
x.Id,
|
||||||
|
x.CourseId,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.Course!.Code,
|
||||||
|
x.Course.Name,
|
||||||
|
x.Course.Credits,
|
||||||
|
x.AcademicTerm!.Name,
|
||||||
|
x.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.ThenBy(item => item.Teacher!.Name)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == x.Id &&
|
||||||
|
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||||||
|
x.SchedulingMode))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return Ok(courses);
|
return Ok(courses);
|
||||||
}
|
}
|
||||||
@@ -341,8 +444,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
if (sid is null) return StudentNotFound();
|
if (sid is null) return StudentNotFound();
|
||||||
var grades = await db.GradeRecords.AsNoTracking()
|
var grades = await db.GradeRecords.AsNoTracking()
|
||||||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||||
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
|
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||||
.OrderBy(x => x.CourseCode)
|
.Select(x => new StudentApprovalGradeOption(
|
||||||
|
x.GradeSheet!.TeachingTask!.CourseId,
|
||||||
|
x.GradeSheet.TeachingTask.Course!.Code,
|
||||||
|
x.GradeSheet.TeachingTask.Course.Name,
|
||||||
|
x.GradeSheet.TeachingTask.Course.Credits,
|
||||||
|
x.TotalScore,
|
||||||
|
x.GradePoint,
|
||||||
|
x.ExamStatus,
|
||||||
|
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.GradeSheet.TeachingTask.TaskNumber,
|
||||||
|
x.GradeSheet.PublishedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return Ok(grades);
|
return Ok(grades);
|
||||||
}
|
}
|
||||||
@@ -377,6 +490,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
|
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||||||
|
Guid studentId,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||||||
|
task.Id == teachingTaskId &&
|
||||||
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
|
!task.AcademicTerm!.IsArchived &&
|
||||||
|
(task.AcademicTerm.IsCurrent ||
|
||||||
|
db.ScheduleEntries.Any(entry =>
|
||||||
|
entry.TeachingTaskId == task.Id &&
|
||||||
|
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||||||
|
(task.Classes.Any(item =>
|
||||||
|
item.AdministrativeClass!.Students.Any(student =>
|
||||||
|
student.Id == studentId)) ||
|
||||||
|
db.CourseEnrollments.Any(enrollment =>
|
||||||
|
enrollment.StudentId == studentId &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
task.Id)),
|
||||||
|
ct);
|
||||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||||
|
|
||||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
||||||
@@ -390,6 +524,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
|||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||||||
|
public sealed record StudentApprovalCourseOption(
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
Guid CourseId,
|
||||||
|
string TaskNumber,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
decimal Credits,
|
||||||
|
string AcademicTermName,
|
||||||
|
IEnumerable<string> TeacherNames,
|
||||||
|
bool HasPublishedSchedule,
|
||||||
|
TeachingTaskSchedulingMode SchedulingMode);
|
||||||
|
public sealed record StudentApprovalGradeOption(
|
||||||
|
Guid CourseId,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
decimal Credits,
|
||||||
|
decimal? TotalScore,
|
||||||
|
decimal? GradePoint,
|
||||||
|
GradeExamStatus ExamStatus,
|
||||||
|
string AcademicTermName,
|
||||||
|
string TaskNumber,
|
||||||
|
DateTime? PublishedAt);
|
||||||
|
public sealed record StudentCourseSubstitutionRecord(
|
||||||
|
Guid Id,
|
||||||
|
ApprovalStatus Status,
|
||||||
|
string Reason,
|
||||||
|
string? ReviewComment,
|
||||||
|
DateTime SubmittedAt,
|
||||||
|
string OriginalCourseCode,
|
||||||
|
string OriginalCourseName,
|
||||||
|
string SubstituteCourseCode,
|
||||||
|
string SubstituteCourseName);
|
||||||
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||||||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
||||||
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ApprovalsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task MyCourses_IncludesPublishedScheduledTaskAfterCurrentTermChanges()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
fixture.CurrentTerm.IsCurrent = false;
|
||||||
|
fixture.Db.AcademicTerms.Add(new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-2",
|
||||||
|
Name = "2026—2027 学年第二学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Spring,
|
||||||
|
StartDate = new DateOnly(2027, 2, 20),
|
||||||
|
EndDate = new DateOnly(2027, 7, 1),
|
||||||
|
IsCurrent = true
|
||||||
|
});
|
||||||
|
fixture.Db.SchedulePlans.Add(new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.CurrentTerm.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "v1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.CurrentTask.Id,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.GetMyEnrolledCourses(
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
var options = Assert.IsAssignableFrom<
|
||||||
|
IEnumerable<StudentApprovalCourseOption>>(ok.Value).ToList();
|
||||||
|
var option = Assert.Single(options);
|
||||||
|
Assert.Equal(fixture.CurrentTask.Id, option.TeachingTaskId);
|
||||||
|
Assert.Equal(fixture.CurrentCourse.Id, option.CourseId);
|
||||||
|
Assert.True(option.HasPublishedSchedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Exemption_RejectsTeachingTaskOutsideStudentsCurrentCourses()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
var unrelatedTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2026-1-OTHER-01",
|
||||||
|
Name = "其他教学班",
|
||||||
|
AcademicTermId = fixture.CurrentTerm.Id,
|
||||||
|
CourseId = fixture.SubstituteCourse.Id,
|
||||||
|
Capacity = 30,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
fixture.Db.TeachingTasks.Add(unrelatedTask);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.ApplyExemption(
|
||||||
|
new ExemptionRequest(unrelatedTask.Id, "申请原因"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
Assert.Empty(fixture.Db.CourseExemptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Substitution_AcceptsCurrentCourseAndPublishedPassedCourse()
|
||||||
|
{
|
||||||
|
await using var fixture = await ApprovalFixture.CreateAsync();
|
||||||
|
var historicalTerm = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2025-2",
|
||||||
|
Name = "2025—2026 学年第二学期",
|
||||||
|
AcademicYear = "2025-2026",
|
||||||
|
Season = TermSeason.Spring,
|
||||||
|
StartDate = new DateOnly(2026, 2, 20),
|
||||||
|
EndDate = new DateOnly(2026, 7, 1)
|
||||||
|
};
|
||||||
|
var historicalTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2025-2-PASS-01",
|
||||||
|
Name = "已通过课程教学班",
|
||||||
|
AcademicTermId = historicalTerm.Id,
|
||||||
|
CourseId = fixture.SubstituteCourse.Id,
|
||||||
|
Capacity = 30,
|
||||||
|
Status = TeachingTaskStatus.Closed
|
||||||
|
};
|
||||||
|
var sheet = new GradeSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = historicalTask.Id,
|
||||||
|
Status = GradeSheetStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow.AddDays(-30),
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new GradeRecord
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
TotalScore = 86,
|
||||||
|
GradePoint = 3.6m
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(historicalTerm, historicalTask, sheet);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Controller.ApplySubstitution(
|
||||||
|
new SubstitutionRequest(
|
||||||
|
fixture.CurrentCourse.Id,
|
||||||
|
fixture.SubstituteCourse.Id,
|
||||||
|
"课程内容相近"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<CreatedResult>(result);
|
||||||
|
var substitution = Assert.Single(fixture.Db.CourseSubstitutions);
|
||||||
|
Assert.Equal(fixture.CurrentCourse.Id, substitution.OriginalCourseId);
|
||||||
|
Assert.Equal(
|
||||||
|
fixture.SubstituteCourse.Id,
|
||||||
|
substitution.SubstituteCourseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ApprovalFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ApprovalFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
Student student,
|
||||||
|
AcademicTerm currentTerm,
|
||||||
|
Course currentCourse,
|
||||||
|
Course substituteCourse,
|
||||||
|
TeachingTask currentTask,
|
||||||
|
ApprovalsController controller)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
Student = student;
|
||||||
|
CurrentTerm = currentTerm;
|
||||||
|
CurrentCourse = currentCourse;
|
||||||
|
SubstituteCourse = substituteCourse;
|
||||||
|
CurrentTask = currentTask;
|
||||||
|
Controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public Student Student { get; }
|
||||||
|
public AcademicTerm CurrentTerm { get; }
|
||||||
|
public Course CurrentCourse { get; }
|
||||||
|
public Course SubstituteCourse { get; }
|
||||||
|
public TeachingTask CurrentTask { get; }
|
||||||
|
public ApprovalsController Controller { get; }
|
||||||
|
|
||||||
|
public static async Task<ApprovalFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "080901",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学学士"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "CS2026-01",
|
||||||
|
Name = "计科 2026-1 班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = "202601001",
|
||||||
|
NormalizedUserName = "202601001",
|
||||||
|
DisplayName = "测试学生"
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "202601001",
|
||||||
|
Name = "测试学生",
|
||||||
|
UserId = user.Id,
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
};
|
||||||
|
var currentTerm = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-1",
|
||||||
|
Name = "2026—2027 学年第一学期",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 20),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var currentCourse = CreateCourse(college.Id, "CS101", "程序设计基础");
|
||||||
|
var substituteCourse = CreateCourse(college.Id, "CS102", "程序设计进阶");
|
||||||
|
var currentTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2026-1-CS101-01",
|
||||||
|
Name = "程序设计基础教学班",
|
||||||
|
AcademicTermId = currentTerm.Id,
|
||||||
|
CourseId = currentCourse.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
user,
|
||||||
|
student,
|
||||||
|
currentTerm,
|
||||||
|
currentCourse,
|
||||||
|
substituteCourse,
|
||||||
|
currentTask);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ApprovalsController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(user.Id));
|
||||||
|
return new ApprovalFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
student,
|
||||||
|
currentTerm,
|
||||||
|
currentCourse,
|
||||||
|
substituteCourse,
|
||||||
|
currentTask,
|
||||||
|
controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Course CreateCourse(
|
||||||
|
Guid collegeId,
|
||||||
|
string code,
|
||||||
|
string name) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Code = code,
|
||||||
|
Name = name,
|
||||||
|
CollegeId = collegeId,
|
||||||
|
Credits = 3,
|
||||||
|
TotalHours = 48,
|
||||||
|
LectureHours = 32,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
userId,
|
||||||
|
"测试学生",
|
||||||
|
null,
|
||||||
|
DataScope.Self,
|
||||||
|
new HashSet<string>([SystemRoles.Student]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,53 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
|
import {
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
Close,
|
||||||
|
DocumentChecked,
|
||||||
|
Plus,
|
||||||
|
Refresh,
|
||||||
|
Search,
|
||||||
|
Switch,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
import http, { apiErrorMessage } from '../api/http'
|
import http, { apiErrorMessage } from '../api/http'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
interface CourseOption {
|
||||||
|
teachingTaskId: string
|
||||||
|
courseId: string
|
||||||
|
taskNumber: string
|
||||||
|
courseCode: string
|
||||||
|
courseName: string
|
||||||
|
credits: number
|
||||||
|
academicTermName: string
|
||||||
|
teacherNames: string[]
|
||||||
|
hasPublishedSchedule: boolean
|
||||||
|
schedulingMode: 'Standard' | 'Flexible'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GradeOption {
|
||||||
|
courseId: string
|
||||||
|
courseCode: string
|
||||||
|
courseName: string
|
||||||
|
credits: number
|
||||||
|
totalScore: number | null
|
||||||
|
gradePoint: number | null
|
||||||
|
examStatus: string
|
||||||
|
academicTermName: string
|
||||||
|
taskNumber: string
|
||||||
|
publishedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubstitutionTarget {
|
||||||
|
courseId: string
|
||||||
|
courseCode: string
|
||||||
|
courseName: string
|
||||||
|
credits: number
|
||||||
|
source: 'assigned' | 'failed'
|
||||||
|
detail: string
|
||||||
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const isManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
|
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 isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
|
||||||
@@ -11,79 +55,259 @@ const isStudent = computed(() => auth.user?.roles.includes('Student') && !isMana
|
|||||||
|
|
||||||
const pending = ref<any[]>([])
|
const pending = ref<any[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
const tab = ref(isManager.value ? 'pending' : 'mine')
|
const tab = ref(isManager.value ? 'pending' : 'mine')
|
||||||
const dialog = ref(false)
|
const dialog = ref(false)
|
||||||
const dialogType = ref('')
|
const dialogType = ref('')
|
||||||
|
const courseKeyword = ref('')
|
||||||
|
const originalKeyword = ref('')
|
||||||
|
const substituteKeyword = ref('')
|
||||||
const rejectDialog = ref(false)
|
const rejectDialog = ref(false)
|
||||||
const rejectItem = ref<any>(null)
|
const rejectItem = ref<any>(null)
|
||||||
const rejectComment = ref('')
|
const rejectComment = ref('')
|
||||||
|
|
||||||
const enrolledCourses = ref<any[]>([])
|
const enrolledCourses = ref<CourseOption[]>([])
|
||||||
const gradeRecords = ref<any[]>([])
|
const gradeRecords = ref<any[]>([])
|
||||||
const myGrades = ref<any[]>([])
|
const myGrades = ref<GradeOption[]>([])
|
||||||
const myExemptions = ref<any[]>([])
|
const myExemptions = ref<any[]>([])
|
||||||
const myDeferred = ref<any[]>([])
|
const myDeferred = ref<any[]>([])
|
||||||
|
const mySubstitutions = ref<any[]>([])
|
||||||
|
|
||||||
const form = reactive({ teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
|
const form = reactive({
|
||||||
|
teachingTaskId: '',
|
||||||
|
reason: '',
|
||||||
|
originalCourseId: '',
|
||||||
|
substituteCourseId: '',
|
||||||
|
gradeRecordId: '',
|
||||||
|
requestedScore: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => ({
|
||||||
|
exemption: '发起免修申请',
|
||||||
|
deferred: '发起缓考申请',
|
||||||
|
gradeMod: '成绩修改申请',
|
||||||
|
substitution: '发起课程替代申请',
|
||||||
|
}[dialogType.value] ?? '发起申请'))
|
||||||
|
|
||||||
|
const selectedCourse = computed(() =>
|
||||||
|
enrolledCourses.value.find(course => course.teachingTaskId === form.teachingTaskId))
|
||||||
|
|
||||||
|
const filteredCourses = computed(() => {
|
||||||
|
const keyword = courseKeyword.value.trim().toLocaleLowerCase()
|
||||||
|
if (!keyword) return enrolledCourses.value
|
||||||
|
return enrolledCourses.value.filter(course =>
|
||||||
|
[course.courseCode, course.courseName, course.taskNumber, ...course.teacherNames]
|
||||||
|
.some(value => value?.toLocaleLowerCase().includes(keyword)))
|
||||||
|
})
|
||||||
|
|
||||||
|
function uniqueGrades(predicate: (grade: GradeOption) => boolean) {
|
||||||
|
const result = new Map<string, GradeOption>()
|
||||||
|
for (const grade of myGrades.value) {
|
||||||
|
if (predicate(grade) && !result.has(grade.courseId)) result.set(grade.courseId, grade)
|
||||||
|
}
|
||||||
|
return [...result.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedGrades = computed(() =>
|
||||||
|
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) < 60))
|
||||||
|
const passedGrades = computed(() =>
|
||||||
|
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) >= 60))
|
||||||
|
|
||||||
|
const substitutionTargets = computed<SubstitutionTarget[]>(() => {
|
||||||
|
const result = new Map<string, SubstitutionTarget>()
|
||||||
|
for (const course of enrolledCourses.value) {
|
||||||
|
result.set(course.courseId, {
|
||||||
|
courseId: course.courseId,
|
||||||
|
courseCode: course.courseCode,
|
||||||
|
courseName: course.courseName,
|
||||||
|
credits: course.credits,
|
||||||
|
source: 'assigned',
|
||||||
|
detail: `${course.academicTermName} · ${course.taskNumber}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const grade of failedGrades.value) {
|
||||||
|
if (!result.has(grade.courseId)) {
|
||||||
|
result.set(grade.courseId, {
|
||||||
|
courseId: grade.courseId,
|
||||||
|
courseCode: grade.courseCode,
|
||||||
|
courseName: grade.courseName,
|
||||||
|
credits: grade.credits,
|
||||||
|
source: 'failed',
|
||||||
|
detail: `${grade.academicTermName} · ${grade.totalScore} 分`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...result.values()]
|
||||||
|
})
|
||||||
|
|
||||||
|
function matchesCourse(item: { courseCode: string; courseName: string }, keyword: string) {
|
||||||
|
const normalized = keyword.trim().toLocaleLowerCase()
|
||||||
|
return !normalized ||
|
||||||
|
item.courseCode.toLocaleLowerCase().includes(normalized) ||
|
||||||
|
item.courseName.toLocaleLowerCase().includes(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOriginal(courseId: string) {
|
||||||
|
form.originalCourseId = courseId
|
||||||
|
if (form.substituteCourseId === courseId) form.substituteCourseId = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOriginalCourses = computed(() =>
|
||||||
|
substitutionTargets.value.filter(item => matchesCourse(item, originalKeyword.value)))
|
||||||
|
const filteredSubstituteCourses = computed(() =>
|
||||||
|
passedGrades.value.filter(item => matchesCourse(item, substituteKeyword.value)))
|
||||||
|
|
||||||
|
const selectedOriginal = computed(() =>
|
||||||
|
substitutionTargets.value.find(item => item.courseId === form.originalCourseId))
|
||||||
|
const selectedSubstitute = computed(() =>
|
||||||
|
passedGrades.value.find(item => item.courseId === form.substituteCourseId))
|
||||||
|
|
||||||
|
const recordCount = computed(() =>
|
||||||
|
myExemptions.value.length + myDeferred.value.length + mySubstitutions.value.length)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
|
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
|
||||||
if (isStudent.value) {
|
if (isStudent.value) {
|
||||||
const [courses, grades, ex, df] = await Promise.all([
|
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
||||||
http.get('/approvals/my-courses'), http.get('/approvals/my-grades'),
|
http.get('/approvals/my-courses'),
|
||||||
http.get('/approvals/exemptions/mine'), http.get('/approvals/deferred/mine'),
|
http.get('/approvals/my-grades'),
|
||||||
|
http.get('/approvals/exemptions/mine'),
|
||||||
|
http.get('/approvals/deferred/mine'),
|
||||||
|
http.get('/approvals/substitutions/mine'),
|
||||||
])
|
])
|
||||||
enrolledCourses.value = courses.data; myGrades.value = grades.data
|
enrolledCourses.value = courses.data
|
||||||
myExemptions.value = ex.data; myDeferred.value = df.data
|
myGrades.value = grades.data
|
||||||
|
myExemptions.value = ex.data
|
||||||
|
myDeferred.value = df.data
|
||||||
|
mySubstitutions.value = substitutions.data
|
||||||
}
|
}
|
||||||
if (isTeacher.value) gradeRecords.value = (await http.get('/approvals/grade-records')).data
|
if (isTeacher.value) gradeRecords.value = (await http.get('/approvals/grade-records')).data
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
} catch (e) {
|
||||||
finally { loading.value = false }
|
ElMessage.error(apiErrorMessage(e))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDialog(type: string) {
|
function openDialog(type: string) {
|
||||||
dialogType.value = type
|
dialogType.value = type
|
||||||
Object.assign(form, { teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
|
courseKeyword.value = ''
|
||||||
|
originalKeyword.value = ''
|
||||||
|
substituteKeyword.value = ''
|
||||||
|
Object.assign(form, {
|
||||||
|
teachingTaskId: '',
|
||||||
|
reason: '',
|
||||||
|
originalCourseId: '',
|
||||||
|
substituteCourseId: '',
|
||||||
|
gradeRecordId: '',
|
||||||
|
requestedScore: 0,
|
||||||
|
})
|
||||||
dialog.value = true
|
dialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitForm() {
|
async function submitForm() {
|
||||||
if (!form.reason.trim()) { ElMessage.warning('请填写原因'); return }
|
if (!form.reason.trim()) {
|
||||||
|
ElMessage.warning('请填写申请原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (dialogType.value === 'exemption') {
|
if (dialogType.value === 'exemption') {
|
||||||
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
|
if (!form.teachingTaskId) {
|
||||||
await http.post('/approvals/exemptions', { teachingTaskId: form.teachingTaskId, reason: form.reason })
|
ElMessage.warning('请选择申请免修的课程')
|
||||||
} else if (dialogType.value === 'deferred') {
|
return
|
||||||
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
|
}
|
||||||
await http.post('/approvals/deferred', { teachingTaskId: form.teachingTaskId, reason: form.reason })
|
await http.post('/approvals/exemptions', {
|
||||||
} else if (dialogType.value === 'gradeMod') {
|
teachingTaskId: form.teachingTaskId,
|
||||||
if (!form.gradeRecordId) { ElMessage.warning('请选择成绩记录'); return }
|
reason: form.reason,
|
||||||
await http.post('/approvals/grade-modifications', { gradeRecordId: form.gradeRecordId, requestedScore: form.requestedScore, reason: form.reason })
|
})
|
||||||
} else if (dialogType.value === 'substitution') {
|
} else if (dialogType.value === 'deferred') {
|
||||||
if (!form.originalCourseId || !form.substituteCourseId) { ElMessage.warning('请选择两门课程'); return }
|
if (!form.teachingTaskId) {
|
||||||
await http.post('/approvals/substitutions', { originalCourseId: form.originalCourseId, substituteCourseId: form.substituteCourseId, reason: form.reason })
|
ElMessage.warning('请选择申请缓考的课程')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await http.post('/approvals/deferred', {
|
||||||
|
teachingTaskId: form.teachingTaskId,
|
||||||
|
reason: form.reason,
|
||||||
|
})
|
||||||
|
} else if (dialogType.value === 'gradeMod') {
|
||||||
|
if (!form.gradeRecordId) {
|
||||||
|
ElMessage.warning('请选择成绩记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await http.post('/approvals/grade-modifications', {
|
||||||
|
gradeRecordId: form.gradeRecordId,
|
||||||
|
requestedScore: form.requestedScore,
|
||||||
|
reason: form.reason,
|
||||||
|
})
|
||||||
|
} else if (dialogType.value === 'substitution') {
|
||||||
|
if (!form.originalCourseId || !form.substituteCourseId) {
|
||||||
|
ElMessage.warning('请完整选择被替代课程和替代课程')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await http.post('/approvals/substitutions', {
|
||||||
|
originalCourseId: form.originalCourseId,
|
||||||
|
substituteCourseId: form.substituteCourseId,
|
||||||
|
reason: form.reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dialog.value = false
|
||||||
|
ElMessage.success('申请已提交')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(apiErrorMessage(e))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
dialog.value = false; ElMessage.success('申请已提交'); await load()
|
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveItem(item: any) {
|
async function approveItem(item: any) {
|
||||||
try {
|
try {
|
||||||
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
|
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`
|
||||||
if (ep) { await http.post(`${ep}/${item.id}/approve`); ElMessage.success('已通过'); await load() }
|
if (ep) {
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
await http.post(`${ep}/${item.id}/approve`)
|
||||||
|
ElMessage.success('已通过')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(apiErrorMessage(e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReject(item: any) { rejectItem.value = item; rejectComment.value = ''; rejectDialog.value = true }
|
function openReject(item: any) {
|
||||||
|
rejectItem.value = item
|
||||||
|
rejectComment.value = ''
|
||||||
|
rejectDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
async function rejectSubmit() {
|
async function rejectSubmit() {
|
||||||
if (!rejectItem.value) return
|
if (!rejectItem.value) return
|
||||||
try {
|
try {
|
||||||
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
|
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`
|
||||||
if (ep) { await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value }); rejectDialog.value = false; ElMessage.success('已驳回'); await load() }
|
if (ep) {
|
||||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value })
|
||||||
|
rejectDialog.value = false
|
||||||
|
ElMessage.success('已驳回')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(apiErrorMessage(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status: string) {
|
||||||
|
return status === 'Submitted' ? '待审核' : status === 'Approved' ? '已通过' : '已驳回'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string) {
|
||||||
|
return status === 'Approved' ? 'success' : status === 'Rejected' ? 'danger' : 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function courseScheduleText(course: CourseOption) {
|
||||||
|
if (course.schedulingMode === 'Flexible') return '非排时课程'
|
||||||
|
return course.hasPublishedSchedule ? '课表已发布' : '等待课表发布'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
@@ -92,92 +316,419 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-stack appr-page">
|
<div class="page-stack appr-page">
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div><span class="section-kicker">APPROVAL CENTER</span><h2>审批中心</h2><p>学籍异动、调停课、成绩审核、考勤申诉、免修、缓考、成绩修改、课程替代。</p></div>
|
<div>
|
||||||
<div style="display:flex;gap:8px">
|
<span class="section-kicker">APPROVAL CENTER</span>
|
||||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('exemption')">免修</el-button>
|
<h2>审批中心</h2>
|
||||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('deferred')">缓考</el-button>
|
<p>{{ isStudent ? '从实际修读课程发起申请,查看审核进度与处理结果。' : '集中处理教务申请与审核任务。' }}</p>
|
||||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('substitution')">课程替代</el-button>
|
|
||||||
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
|
|
||||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-segmented v-model="tab" :options="[
|
<section v-if="isStudent" v-loading="loading" class="application-launcher">
|
||||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
<button class="launch-card exemption" type="button" @click="openDialog('exemption')">
|
||||||
{ label: '我的记录', value: 'mine' },
|
<span class="launch-icon"><DocumentChecked /></span>
|
||||||
]" style="margin-bottom:16px" />
|
<span class="launch-copy">
|
||||||
|
<small>COURSE EXEMPTION</small>
|
||||||
|
<b>申请免修</b>
|
||||||
|
<em>从当前或已发布课表的修读课程中选择</em>
|
||||||
|
</span>
|
||||||
|
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
|
||||||
|
</button>
|
||||||
|
<button class="launch-card deferred" type="button" @click="openDialog('deferred')">
|
||||||
|
<span class="launch-icon"><Clock /></span>
|
||||||
|
<span class="launch-copy">
|
||||||
|
<small>DEFERRED EXAM</small>
|
||||||
|
<b>申请缓考</b>
|
||||||
|
<em>课程与教学班信息一并提交</em>
|
||||||
|
</span>
|
||||||
|
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
|
||||||
|
</button>
|
||||||
|
<button class="launch-card substitution" type="button" @click="openDialog('substitution')">
|
||||||
|
<span class="launch-icon"><Switch /></span>
|
||||||
|
<span class="launch-copy">
|
||||||
|
<small>COURSE SUBSTITUTION</small>
|
||||||
|
<b>申请课程替代</b>
|
||||||
|
<em>修读中或未通过课程 → 已通过课程</em>
|
||||||
|
</span>
|
||||||
|
<span class="launch-meta">{{ passedGrades.length }} 门可替代 <Plus /></span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="approval-tabs">
|
||||||
|
<el-segmented
|
||||||
|
v-model="tab"
|
||||||
|
:options="[
|
||||||
|
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
||||||
|
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pending -->
|
|
||||||
<section v-if="tab === 'pending'" v-loading="loading" class="appr-list">
|
<section v-if="tab === 'pending'" v-loading="loading" class="appr-list">
|
||||||
<article v-for="item in pending" :key="`${item.type}-${item.id}`" class="appr-card">
|
<article v-for="item in pending" :key="`${item.type}-${item.id}`" class="appr-card">
|
||||||
<el-tag size="small" :type="item.label === '考勤申诉' ? 'warning' : 'primary'">{{ item.label }}</el-tag>
|
<el-tag size="small" :type="item.label === '考勤申诉' ? 'warning' : 'primary'">{{ item.label }}</el-tag>
|
||||||
<div class="appr-body"><b>{{ item.title }}</b><p>{{ item.desc }}</p><small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small></div>
|
<div class="appr-body">
|
||||||
<div class="appr-actions" v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)">
|
<b>{{ item.title }}</b>
|
||||||
|
<p>{{ item.desc }}</p>
|
||||||
|
<small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small>
|
||||||
|
</div>
|
||||||
|
<div v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)" class="appr-actions">
|
||||||
<el-button size="small" type="danger" plain :icon="Close" @click="openReject(item)">驳回</el-button>
|
<el-button size="small" type="danger" plain :icon="Close" @click="openReject(item)">驳回</el-button>
|
||||||
<el-button size="small" type="success" :icon="Check" @click="approveItem(item)">通过</el-button>
|
<el-button size="small" type="success" :icon="Check" @click="approveItem(item)">通过</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="appr-actions" v-else><el-button size="small" text type="primary" @click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')">查看 →</el-button></div>
|
<div v-else class="appr-actions">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
@click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')"
|
||||||
|
>
|
||||||
|
查看 →
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<el-empty v-if="!pending.length" description="暂无待审批" />
|
<el-empty v-if="!pending.length" description="暂无待审批" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- My records -->
|
|
||||||
<section v-if="tab === 'mine'" v-loading="loading">
|
<section v-if="tab === 'mine'" v-loading="loading">
|
||||||
<template v-if="isStudent">
|
<template v-if="isStudent">
|
||||||
<h4 style="margin-bottom:8px">免修申请</h4>
|
<div v-if="recordCount" class="record-columns">
|
||||||
<div class="appr-list" style="margin-bottom:16px">
|
<section class="record-group">
|
||||||
<article v-for="e in myExemptions" :key="e.id" class="appr-card"><el-tag size="small" :type="e.status === 'Approved' ? 'success' : e.status === 'Rejected' ? 'danger' : 'warning'">{{ e.status === 'Submitted' ? '待审核' : e.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ e.courseName }}</b><p>{{ e.reason }}</p><small v-if="e.reviewComment">{{ e.reviewComment }}</small></div></article>
|
<header><span class="record-mark exemption"></span><b>免修申请</b><em>{{ myExemptions.length }}</em></header>
|
||||||
<el-empty v-if="!myExemptions.length" description="无" />
|
<div class="appr-list">
|
||||||
|
<article v-for="item in myExemptions" :key="item.id" class="appr-card compact">
|
||||||
|
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||||
|
<div class="appr-body">
|
||||||
|
<b>{{ item.courseName }}</b>
|
||||||
|
<p>{{ item.reason }}</p>
|
||||||
|
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||||
</div>
|
</div>
|
||||||
<h4 style="margin-bottom:8px">缓考申请</h4>
|
</article>
|
||||||
<div class="appr-list" style="margin-bottom:16px">
|
<p v-if="!myExemptions.length" class="record-empty">尚未提交免修申请</p>
|
||||||
<article v-for="d in myDeferred" :key="d.id" class="appr-card"><el-tag size="small" :type="d.status === 'Approved' ? 'success' : d.status === 'Rejected' ? 'danger' : 'warning'">{{ d.status === 'Submitted' ? '待审核' : d.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ d.courseName }}</b><p>{{ d.reason }}</p></div></article>
|
|
||||||
<el-empty v-if="!myDeferred.length" description="无" />
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="record-group">
|
||||||
|
<header><span class="record-mark deferred"></span><b>缓考申请</b><em>{{ myDeferred.length }}</em></header>
|
||||||
|
<div class="appr-list">
|
||||||
|
<article v-for="item in myDeferred" :key="item.id" class="appr-card compact">
|
||||||
|
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||||
|
<div class="appr-body">
|
||||||
|
<b>{{ item.courseName }}</b>
|
||||||
|
<p>{{ item.reason }}</p>
|
||||||
|
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<p v-if="!myDeferred.length" class="record-empty">尚未提交缓考申请</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="record-group substitution-records">
|
||||||
|
<header><span class="record-mark substitution"></span><b>课程替代申请</b><em>{{ mySubstitutions.length }}</em></header>
|
||||||
|
<div class="appr-list">
|
||||||
|
<article v-for="item in mySubstitutions" :key="item.id" class="appr-card compact">
|
||||||
|
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||||
|
<div class="appr-body">
|
||||||
|
<b>{{ item.substituteCourseCode }} {{ item.substituteCourseName }} → {{ item.originalCourseCode }} {{ item.originalCourseName }}</b>
|
||||||
|
<p>{{ item.reason }}</p>
|
||||||
|
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<p v-if="!mySubstitutions.length" class="record-empty">尚未提交课程替代申请</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="还没有申请记录">
|
||||||
|
<el-button type="primary" @click="openDialog('exemption')">发起第一项申请</el-button>
|
||||||
|
</el-empty>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Dialog -->
|
<el-dialog v-model="dialog" :title="dialogTitle" width="min(780px, calc(100vw - 24px))" class="application-dialog" destroy-on-close>
|
||||||
<el-dialog v-model="dialog" :title="dialogType === 'exemption' ? '免修申请' : dialogType === 'deferred' ? '缓考申请' : dialogType === 'gradeMod' ? '成绩修改申请' : '课程替代申请'" width="560px">
|
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
<template v-if="dialogType === 'exemption' || dialogType === 'deferred'">
|
<template v-if="dialogType === 'exemption' || dialogType === 'deferred'">
|
||||||
<el-form-item label="选择课程" required>
|
<div class="dialog-lead">
|
||||||
<el-select v-model="form.teachingTaskId" filterable><el-option v-for="c in enrolledCourses" :key="c.teachingTaskId" :label="`${c.courseCode} ${c.courseName}`" :value="c.teachingTaskId" /></el-select>
|
<span :class="['dialog-lead-icon', dialogType]">
|
||||||
|
<DocumentChecked v-if="dialogType === 'exemption'" />
|
||||||
|
<Clock v-else />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<b>{{ dialogType === 'exemption' ? '选择申请免修的教学班' : '选择申请缓考的教学班' }}</b>
|
||||||
|
<p>列表包含当前学期课程,以及仍未归档且课表已发布的行政班或已选课程。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="课程" required>
|
||||||
|
<el-input v-model="courseKeyword" :prefix-icon="Search" clearable placeholder="搜索课程名称、代码、教学班号或教师" />
|
||||||
|
<div v-if="filteredCourses.length" class="course-picker">
|
||||||
|
<button
|
||||||
|
v-for="course in filteredCourses"
|
||||||
|
:key="course.teachingTaskId"
|
||||||
|
type="button"
|
||||||
|
:class="['course-choice', { selected: form.teachingTaskId === course.teachingTaskId }]"
|
||||||
|
@click="form.teachingTaskId = course.teachingTaskId"
|
||||||
|
>
|
||||||
|
<span class="course-code">{{ course.courseCode }}</span>
|
||||||
|
<span class="course-main">
|
||||||
|
<b>{{ course.courseName }}</b>
|
||||||
|
<small>{{ course.taskNumber }} · {{ course.academicTermName }}</small>
|
||||||
|
<em>{{ course.teacherNames.length ? course.teacherNames.join('、') : '教师待定' }} · {{ course.credits }} 学分</em>
|
||||||
|
</span>
|
||||||
|
<span :class="['schedule-state', { ready: course.hasPublishedSchedule || course.schedulingMode === 'Flexible' }]">
|
||||||
|
{{ courseScheduleText(course) }}
|
||||||
|
</span>
|
||||||
|
<span class="choice-check"><Check /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-else
|
||||||
|
:description="enrolledCourses.length ? '没有匹配的课程' : '暂无可申请课程'"
|
||||||
|
:image-size="76"
|
||||||
|
>
|
||||||
|
<p v-if="!enrolledCourses.length" class="empty-explanation">
|
||||||
|
课程需为已发布教学任务,并分配到您的行政班或已完成选课;当前学期课程或已发布课表的课程均会显示。
|
||||||
|
</p>
|
||||||
|
</el-empty>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="dialogType === 'substitution'">
|
<template v-if="dialogType === 'substitution'">
|
||||||
<el-form-item label="未通过课程(被替代)" required>
|
<el-alert
|
||||||
<el-select v-model="form.originalCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) < 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
|
title="替代关系须同时满足两个条件"
|
||||||
</el-form-item>
|
description="被替代课程应为当前或已发布课表的修读课程,或已有未通过成绩的课程;替代课程必须已有正式发布且不低于 60 分的成绩。"
|
||||||
<el-form-item label="已通过课程(替代课程)" required>
|
type="info"
|
||||||
<el-select v-model="form.substituteCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) >= 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
|
:closable="false"
|
||||||
</el-form-item>
|
show-icon
|
||||||
|
/>
|
||||||
|
<div class="substitution-flow">
|
||||||
|
<section class="substitution-column">
|
||||||
|
<header><span>1</span><div><b>被替代课程</b><small>修读课程 / 历史未通过</small></div></header>
|
||||||
|
<el-input v-model="originalKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
|
||||||
|
<div v-if="filteredOriginalCourses.length" class="mini-course-list">
|
||||||
|
<button
|
||||||
|
v-for="course in filteredOriginalCourses"
|
||||||
|
:key="course.courseId"
|
||||||
|
type="button"
|
||||||
|
:class="{ selected: form.originalCourseId === course.courseId }"
|
||||||
|
@click="selectOriginal(course.courseId)"
|
||||||
|
>
|
||||||
|
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.detail }} · {{ course.credits }} 学分</small></span>
|
||||||
|
<el-tag size="small" :type="course.source === 'assigned' ? 'primary' : 'danger'">
|
||||||
|
{{ course.source === 'assigned' ? '修读' : '未通过' }}
|
||||||
|
</el-tag>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else :image-size="62" description="暂无可被替代课程" />
|
||||||
|
</section>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<section class="substitution-column">
|
||||||
|
<header><span>2</span><div><b>替代课程</b><small>已发布且成绩及格</small></div></header>
|
||||||
|
<el-input v-model="substituteKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
|
||||||
|
<div v-if="filteredSubstituteCourses.length" class="mini-course-list">
|
||||||
|
<button
|
||||||
|
v-for="course in filteredSubstituteCourses"
|
||||||
|
:key="course.courseId"
|
||||||
|
type="button"
|
||||||
|
:disabled="course.courseId === form.originalCourseId"
|
||||||
|
:class="{ selected: form.substituteCourseId === course.courseId }"
|
||||||
|
@click="form.substituteCourseId = course.courseId"
|
||||||
|
>
|
||||||
|
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.academicTermName }} · {{ course.credits }} 学分</small></span>
|
||||||
|
<strong>{{ course.totalScore }}<small>分</small></strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else :image-size="62" description="暂无已通过课程">
|
||||||
|
<p class="empty-explanation">已排课但尚未发布成绩的课程不属于可替代课程。</p>
|
||||||
|
</el-empty>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="dialogType === 'gradeMod'">
|
<template v-if="dialogType === 'gradeMod'">
|
||||||
<el-form-item label="成绩记录" required>
|
<el-form-item label="成绩记录" required>
|
||||||
<el-select v-model="form.gradeRecordId" filterable><el-option v-for="r in gradeRecords" :key="r.id" :label="`${r.studentName}(${r.studentNumber}) — ${r.courseName} — ${r.totalScore}分`" :value="r.id" /></el-select>
|
<el-select v-model="form.gradeRecordId" filterable placeholder="选择已发布成绩">
|
||||||
|
<el-option
|
||||||
|
v-for="record in gradeRecords"
|
||||||
|
:key="record.id"
|
||||||
|
:label="`${record.studentName}(${record.studentNumber}) — ${record.courseName} — ${record.totalScore}分`"
|
||||||
|
:value="record.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="修改为" required>
|
||||||
|
<el-input-number v-model="form.requestedScore" :min="0" :max="100" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="修改为" required><el-input-number v-model="form.requestedScore" :min="0" :max="100" /></el-form-item>
|
|
||||||
</template>
|
</template>
|
||||||
<el-form-item label="原因" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
|
||||||
|
<el-form-item class="reason-field" label="申请原因" required>
|
||||||
|
<el-input
|
||||||
|
v-model="form.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="dialogType === 'deferred' ? '请说明无法按时参加考试的原因及相关情况' : dialogType === 'substitution' ? '请说明两门课程在内容、学分或培养要求上的对应关系' : '请说明申请依据及相关情况'"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="submitForm">提交申请</el-button></template>
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<span v-if="selectedCourse">{{ selectedCourse.courseCode }} · {{ selectedCourse.courseName }}</span>
|
||||||
|
<span v-else-if="selectedOriginal || selectedSubstitute">
|
||||||
|
{{ selectedSubstitute?.courseName ?? '请选择替代课程' }} → {{ selectedOriginal?.courseName ?? '请选择被替代课程' }}
|
||||||
|
</span>
|
||||||
|
<el-button @click="dialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm">提交申请</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- Reject -->
|
<el-dialog v-model="rejectDialog" title="驳回申请" width="min(460px, calc(100vw - 24px))">
|
||||||
<el-dialog v-model="rejectDialog" title="驳回" width="460px">
|
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写驳回原因" />
|
||||||
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="驳回原因" />
|
<template #footer>
|
||||||
<template #footer><el-button @click="rejectDialog = false">取消</el-button><el-button type="danger" @click="rejectSubmit">确认驳回</el-button></template>
|
<el-button @click="rejectDialog = false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="rejectSubmit">确认驳回</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.application-launcher {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
color: #1e293b;
|
||||||
|
background: linear-gradient(145deg, #fff 48%, #f6f8fb);
|
||||||
|
border: 1px solid #dfe4eb;
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -26px;
|
||||||
|
bottom: -50px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 18px solid rgba(67, 97, 238, .05);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #aebbd0;
|
||||||
|
box-shadow: 0 12px 28px rgba(30, 41, 59, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-icon, .dialog-lead-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #3556b5;
|
||||||
|
background: #edf2ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launch-icon svg, .dialog-lead-icon svg { width: 21px; }
|
||||||
|
.deferred .launch-icon, .dialog-lead-icon.deferred { color: #a85d00; background: #fff3dc; }
|
||||||
|
.substitution .launch-icon { color: #087f5b; background: #e8f8f1; }
|
||||||
|
.launch-copy { display: grid; gap: 3px; min-width: 0; }
|
||||||
|
.launch-copy small { color: #8791a3; font: 600 9px/1.2 ui-monospace, monospace; letter-spacing: .1em; }
|
||||||
|
.launch-copy b { margin-top: 3px; font-size: 17px; }
|
||||||
|
.launch-copy em { color: #667085; font-size: 11px; font-style: normal; line-height: 1.5; }
|
||||||
|
.launch-meta { position: absolute; left: 76px; bottom: 17px; display: flex; align-items: center; gap: 5px; color: #506079; font-size: 10px; }
|
||||||
|
.launch-meta svg { width: 12px; }
|
||||||
|
|
||||||
|
.approval-tabs { display: flex; justify-content: space-between; gap: 12px; }
|
||||||
.appr-list { display: grid; gap: 8px; }
|
.appr-list { display: grid; gap: 8px; }
|
||||||
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; }
|
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 10px; }
|
||||||
|
.appr-card.compact { align-items: flex-start; padding: 13px; }
|
||||||
.appr-body { flex: 1; min-width: 0; }
|
.appr-body { flex: 1; min-width: 0; }
|
||||||
.appr-body b { font-size: 14px; display: block; margin-bottom: 4px; }
|
.appr-body b { display: block; margin-bottom: 4px; font-size: 13px; }
|
||||||
.appr-body p { font-size: 13px; color: #606266; margin: 0 0 4px; }
|
.appr-body p { margin: 0 0 4px; color: #606266; font-size: 12px; line-height: 1.55; }
|
||||||
.appr-body small { font-size: 11px; color: var(--muted); }
|
.appr-body small { color: var(--muted); font-size: 10px; }
|
||||||
.appr-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
.appr-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||||
|
|
||||||
|
.record-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||||
|
.record-group { padding: 16px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
|
||||||
|
.record-group.substitution-records { grid-column: 1 / -1; }
|
||||||
|
.record-group > header { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.record-group > header b { font-size: 13px; }
|
||||||
|
.record-group > header em { margin-left: auto; min-width: 22px; padding: 3px 7px; color: #667085; background: #fff; border-radius: 10px; font-size: 10px; font-style: normal; text-align: center; }
|
||||||
|
.record-mark { width: 8px; height: 8px; background: #4b6fd8; border-radius: 50%; box-shadow: 0 0 0 4px #e8edfb; }
|
||||||
|
.record-mark.deferred { background: #d5841c; box-shadow: 0 0 0 4px #fff0d8; }
|
||||||
|
.record-mark.substitution { background: #13916a; box-shadow: 0 0 0 4px #dff5ed; }
|
||||||
|
.record-empty { margin: 20px 0; color: #98a2b3; font-size: 11px; text-align: center; }
|
||||||
|
|
||||||
|
.dialog-lead { margin-bottom: 18px; padding: 14px; display: flex; align-items: center; gap: 12px; background: #f7f9fc; border: 1px solid #e6eaf1; border-radius: 10px; }
|
||||||
|
.dialog-lead b { display: block; margin-bottom: 3px; font-size: 13px; }
|
||||||
|
.dialog-lead p { margin: 0; color: #667085; font-size: 11px; }
|
||||||
|
.course-picker { width: 100%; max-height: 330px; margin-top: 10px; display: grid; gap: 8px; overflow: auto; }
|
||||||
|
.course-choice { width: 100%; padding: 13px; display: grid; grid-template-columns: 68px minmax(0, 1fr) auto 20px; align-items: center; gap: 12px; text-align: left; background: #fff; border: 1px solid #e1e6ee; border-radius: 10px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||||
|
.course-choice:hover { border-color: #9aabd4; }
|
||||||
|
.course-choice.selected { background: #f4f7ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
|
||||||
|
.course-code { color: #3651a3; font: 700 11px/1.3 ui-monospace, monospace; }
|
||||||
|
.course-main { display: grid; min-width: 0; gap: 3px; }
|
||||||
|
.course-main b { color: #1e293b; font-size: 13px; }
|
||||||
|
.course-main small, .course-main em { overflow: hidden; color: #7b8495; font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.schedule-state { padding: 4px 7px; color: #8a6116; background: #fff4d9; border-radius: 6px; font-size: 9px; }
|
||||||
|
.schedule-state.ready { color: #087f5b; background: #e5f7ef; }
|
||||||
|
.choice-check { display: grid; place-items: center; width: 18px; height: 18px; color: transparent; border: 1px solid #cfd5df; border-radius: 50%; }
|
||||||
|
.choice-check svg { width: 11px; }
|
||||||
|
.course-choice.selected .choice-check { color: #fff; background: #4b6fd8; border-color: #4b6fd8; }
|
||||||
|
.empty-explanation { max-width: 450px; margin: -10px auto 0; color: #8992a3; font-size: 10px; line-height: 1.6; }
|
||||||
|
|
||||||
|
.substitution-flow { margin-top: 16px; display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); align-items: stretch; gap: 10px; }
|
||||||
|
.substitution-column { min-width: 0; padding: 14px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
|
||||||
|
.substitution-column > header { margin-bottom: 12px; display: flex; align-items: center; gap: 9px; }
|
||||||
|
.substitution-column > header > span { width: 25px; height: 25px; display: grid; place-items: center; color: #fff; background: #405fb7; border-radius: 8px; font: 700 11px/1 ui-monospace, monospace; }
|
||||||
|
.substitution-column > header div { display: grid; gap: 2px; }
|
||||||
|
.substitution-column > header b { font-size: 12px; }
|
||||||
|
.substitution-column > header small { color: #8a94a5; font-size: 9px; }
|
||||||
|
.flow-arrow { display: grid; place-items: center; color: #7d8fbf; font-size: 18px; }
|
||||||
|
.mini-course-list { max-height: 255px; margin-top: 9px; display: grid; align-content: start; gap: 6px; overflow: auto; }
|
||||||
|
.mini-course-list button { padding: 10px; display: flex; align-items: center; gap: 8px; text-align: left; background: #fff; border: 1px solid #e2e7ee; border-radius: 8px; cursor: pointer; }
|
||||||
|
.mini-course-list button:hover { border-color: #a9b5d3; }
|
||||||
|
.mini-course-list button.selected { background: #f0f4ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
|
||||||
|
.mini-course-list button:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.mini-course-list button > span { min-width: 0; display: grid; flex: 1; gap: 3px; }
|
||||||
|
.mini-course-list button b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mini-course-list button span small { color: #8a94a5; font-size: 9px; }
|
||||||
|
.mini-course-list button > strong { color: #087f5b; font-size: 16px; }
|
||||||
|
.mini-course-list button > strong small { margin-left: 1px; font-size: 8px; font-weight: 500; }
|
||||||
|
.reason-field { margin-top: 18px; }
|
||||||
|
.dialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||||
|
.dialog-footer > span { margin-right: auto; overflow: hidden; color: #667085; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.application-launcher { grid-template-columns: 1fr; }
|
||||||
|
.launch-card { min-height: 112px; }
|
||||||
|
.record-columns { grid-template-columns: 1fr; }
|
||||||
|
.record-group.substitution-records { grid-column: auto; }
|
||||||
|
.substitution-flow { grid-template-columns: 1fr; }
|
||||||
|
.flow-arrow { transform: rotate(90deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.page-intro { align-items: flex-start; }
|
||||||
|
.approval-tabs { align-items: flex-start; }
|
||||||
|
.course-choice { grid-template-columns: 58px minmax(0, 1fr) 18px; }
|
||||||
|
.schedule-state { grid-column: 2; justify-self: start; }
|
||||||
|
.appr-card { align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.appr-actions { width: 100%; justify-content: flex-end; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user