Compare commits
@@ -17,3 +17,4 @@ src/Jiaowu.Api/wwwroot/
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
certs/
|
||||
publish/
|
||||
@@ -80,6 +80,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var studentId = await GetStudentIdAsync(ct);
|
||||
if (studentId is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
studentId.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
if (await db.CourseExemptions.AnyAsync(x => x.StudentId == studentId && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有免修申请在审核中。");
|
||||
var ex = new CourseExemption { StudentId = studentId.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||
@@ -141,6 +144,9 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (!await IsStudentCourseAvailableForApplicationAsync(
|
||||
sid.Value, req.TeachingTaskId, ct))
|
||||
return ConflictProblem("该课程不在您当前或已发布课表的修读课程中,请刷新课程列表后重试。");
|
||||
if (await db.DeferredExams.AnyAsync(x => x.StudentId == sid && x.TeachingTaskId == req.TeachingTaskId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有缓考申请在审核中。");
|
||||
var d = new DeferredExam { StudentId = sid.Value, TeachingTaskId = req.TeachingTaskId, Reason = req.Reason.Trim() };
|
||||
@@ -248,12 +254,75 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
// ═══════════════ Course Substitution ═══════════════
|
||||
[HttpGet("substitutions/mine")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMySubstitutions(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
return Ok(await db.CourseSubstitutions.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Select(x => new StudentCourseSubstitutionRecord(
|
||||
x.Id,
|
||||
x.Status,
|
||||
x.Reason,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.OriginalCourse!.Code,
|
||||
x.OriginalCourse.Name,
|
||||
x.SubstituteCourse!.Code,
|
||||
x.SubstituteCourse.Name))
|
||||
.ToListAsync(ct));
|
||||
}
|
||||
|
||||
[HttpPost("substitutions")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> ApplySubstitution(SubstitutionRequest req, CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
if (req.OriginalCourseId == req.SubstituteCourseId)
|
||||
return ConflictProblem("被替代课程和替代课程不能相同。");
|
||||
var isAssignedCourse = await db.TeachingTasks.AsNoTracking()
|
||||
.AnyAsync(task =>
|
||||
task.CourseId == req.OriginalCourseId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
var hasFailedOriginal = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(record =>
|
||||
record.StudentId == sid.Value &&
|
||||
record.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
record.GradeSheet.TeachingTask!.CourseId ==
|
||||
req.OriginalCourseId &&
|
||||
record.TotalScore < 60,
|
||||
ct);
|
||||
var canReplaceOriginal = isAssignedCourse || hasFailedOriginal;
|
||||
if (!canReplaceOriginal)
|
||||
return ConflictProblem("被替代课程必须是当前或已发布课表的修读课程,或已有未通过成绩的课程。");
|
||||
var hasPassedSubstitute = await db.GradeRecords.AsNoTracking()
|
||||
.AnyAsync(x =>
|
||||
x.StudentId == sid.Value &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradeSheet.TeachingTask!.CourseId == req.SubstituteCourseId &&
|
||||
x.TotalScore >= 60,
|
||||
ct);
|
||||
if (!hasPassedSubstitute)
|
||||
return ConflictProblem("替代课程必须具有已发布且及格的成绩。");
|
||||
if (await db.CourseSubstitutions.AnyAsync(x => x.StudentId == sid && x.OriginalCourseId == req.OriginalCourseId && x.Status == ApprovalStatus.Submitted, ct))
|
||||
return ConflictProblem("该课程已有替代申请在审核中。");
|
||||
var cs = new CourseSubstitution { StudentId = sid.Value, OriginalCourseId = req.OriginalCourseId, SubstituteCourseId = req.SubstituteCourseId, Reason = req.Reason.Trim() };
|
||||
@@ -319,16 +388,50 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Student enrolled courses ═══════════════
|
||||
// ═══════════════ Student course options ═══════════════
|
||||
[HttpGet("my-courses")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMyEnrolledCourses(CancellationToken ct)
|
||||
{
|
||||
var sid = await GetStudentIdAsync(ct);
|
||||
if (sid is null) return StudentNotFound();
|
||||
var courses = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid && x.Status == CourseEnrollmentStatus.Enrolled)
|
||||
.Select(x => new { x.CourseSelectionOffering!.TeachingTaskId, x.CourseSelectionOffering.TeachingTask!.TaskNumber, CourseCode = x.CourseSelectionOffering.TeachingTask.Course!.Code, CourseName = x.CourseSelectionOffering.TeachingTask.Course.Name })
|
||||
var courses = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status ==
|
||||
SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == sid.Value)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == sid.Value &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)))
|
||||
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
|
||||
.ThenByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.Course!.Code)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new StudentApprovalCourseOption(
|
||||
x.Id,
|
||||
x.CourseId,
|
||||
x.TaskNumber,
|
||||
x.Course!.Code,
|
||||
x.Course.Name,
|
||||
x.Course.Credits,
|
||||
x.AcademicTerm!.Name,
|
||||
x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.ThenBy(item => item.Teacher!.Name)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == x.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published),
|
||||
x.SchedulingMode))
|
||||
.ToListAsync(ct);
|
||||
return Ok(courses);
|
||||
}
|
||||
@@ -341,8 +444,18 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
if (sid is null) return StudentNotFound();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.StudentId == sid && x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Select(x => new { CourseId = x.GradeSheet!.TeachingTask!.CourseId, CourseCode = x.GradeSheet.TeachingTask.Course!.Code, CourseName = x.GradeSheet.TeachingTask.Course.Name, x.TotalScore, x.GradePoint, x.ExamStatus })
|
||||
.OrderBy(x => x.CourseCode)
|
||||
.OrderByDescending(x => x.GradeSheet!.PublishedAt)
|
||||
.Select(x => new StudentApprovalGradeOption(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.GradeSheet.TeachingTask.Course!.Code,
|
||||
x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
x.ExamStatus,
|
||||
x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTask.TaskNumber,
|
||||
x.GradeSheet.PublishedAt))
|
||||
.ToListAsync(ct);
|
||||
return Ok(grades);
|
||||
}
|
||||
@@ -377,6 +490,27 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetStudentIdAsync(CancellationToken ct) => await db.Students.Where(s => s.UserId == scope.Current.UserId).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||
private async Task<bool> IsStudentCourseAvailableForApplicationAsync(
|
||||
Guid studentId,
|
||||
Guid teachingTaskId,
|
||||
CancellationToken ct) =>
|
||||
await db.TeachingTasks.AsNoTracking().AnyAsync(task =>
|
||||
task.Id == teachingTaskId &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
!task.AcademicTerm!.IsArchived &&
|
||||
(task.AcademicTerm.IsCurrent ||
|
||||
db.ScheduleEntries.Any(entry =>
|
||||
entry.TeachingTaskId == task.Id &&
|
||||
entry.SchedulePlan!.Status == SchedulePlanStatus.Published)) &&
|
||||
(task.Classes.Any(item =>
|
||||
item.AdministrativeClass!.Students.Any(student =>
|
||||
student.Id == studentId)) ||
|
||||
db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.StudentId == studentId &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId ==
|
||||
task.Id)),
|
||||
ct);
|
||||
private ActionResult StudentNotFound() => Conflict(new ProblemDetails { Title = "未关联学生档案", Detail = "当前账号未关联有效学生档案。", Status = 409 });
|
||||
|
||||
private async Task NotifyManagers(string title, string content, CancellationToken ct) { await NotificationService.SendToRoleAsync(db, SystemRoles.CollegeAdmin, title, content, cancellationToken: ct); await NotificationService.SendToRoleAsync(db, SystemRoles.AcademicAdmin, title, content, cancellationToken: ct); }
|
||||
@@ -390,6 +524,38 @@ public sealed class ApprovalsController(AppDbContext db, ICurrentUserDataScope s
|
||||
}
|
||||
|
||||
public sealed record ApprovalItem(Guid Id, string Type, string Label, string Title, string Desc, DateTime Time, string College);
|
||||
public sealed record StudentApprovalCourseOption(
|
||||
Guid TeachingTaskId,
|
||||
Guid CourseId,
|
||||
string TaskNumber,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
string AcademicTermName,
|
||||
IEnumerable<string> TeacherNames,
|
||||
bool HasPublishedSchedule,
|
||||
TeachingTaskSchedulingMode SchedulingMode);
|
||||
public sealed record StudentApprovalGradeOption(
|
||||
Guid CourseId,
|
||||
string CourseCode,
|
||||
string CourseName,
|
||||
decimal Credits,
|
||||
decimal? TotalScore,
|
||||
decimal? GradePoint,
|
||||
GradeExamStatus ExamStatus,
|
||||
string AcademicTermName,
|
||||
string TaskNumber,
|
||||
DateTime? PublishedAt);
|
||||
public sealed record StudentCourseSubstitutionRecord(
|
||||
Guid Id,
|
||||
ApprovalStatus Status,
|
||||
string Reason,
|
||||
string? ReviewComment,
|
||||
DateTime SubmittedAt,
|
||||
string OriginalCourseCode,
|
||||
string OriginalCourseName,
|
||||
string SubstituteCourseCode,
|
||||
string SubstituteCourseName);
|
||||
public sealed record ExemptionRequest(Guid TeachingTaskId, [Required, MaxLength(500)] string Reason);
|
||||
public sealed record GradeModRequest(Guid GradeRecordId, decimal RequestedScore, [Required, MaxLength(500)] string Reason);
|
||||
public sealed record SubstitutionRequest(Guid OriginalCourseId, Guid SubstituteCourseId, [Required, MaxLength(500)] string Reason);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using ClosedXML.Excel;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
@@ -76,8 +77,12 @@ public sealed class AttendanceController(
|
||||
x.Name,
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.Notes,
|
||||
x.SubmittedAt,
|
||||
CheckedInCount = x.Records.Count(r => r.CheckInAt != null),
|
||||
PresentCount = x.Records.Count(r => r.Status == AttendanceStatus.Present),
|
||||
AbsentCount = x.Records.Count(r => r.Status == AttendanceStatus.Absent),
|
||||
LateCount = x.Records.Count(r => r.Status == AttendanceStatus.Late),
|
||||
@@ -109,20 +114,71 @@ public sealed class AttendanceController(
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("该教学班没有有效选课学生。");
|
||||
|
||||
var checkInMethod = request.CheckInMethod ?? AttendanceCheckInMethod.Manual;
|
||||
if (!Enum.IsDefined(checkInMethod))
|
||||
return ConflictProblem("不支持该签到方式。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
DateTime? checkInEndsAt = null;
|
||||
string? checkInToken = null;
|
||||
if (checkInMethod != AttendanceCheckInMethod.Manual)
|
||||
{
|
||||
if (request.CheckInDurationMinutes is < 1 or > 180)
|
||||
return ConflictProblem("签到时长应为 1 至 180 分钟。");
|
||||
checkInEndsAt = now.AddMinutes(request.CheckInDurationMinutes!.Value);
|
||||
}
|
||||
if (checkInMethod == AttendanceCheckInMethod.QrCode)
|
||||
checkInToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(24));
|
||||
if (checkInMethod == AttendanceCheckInMethod.Location)
|
||||
{
|
||||
if (request.TargetLatitude is < -90 or > 90 ||
|
||||
request.TargetLongitude is < -180 or > 180 ||
|
||||
request.TargetLatitude is null ||
|
||||
request.TargetLongitude is null)
|
||||
return ConflictProblem("请获取有效的签到位置。");
|
||||
if (request.LocationRadiusMeters is < 20 or > 1000)
|
||||
return ConflictProblem("定位签到范围应为 20 至 1000 米。");
|
||||
}
|
||||
|
||||
var sheet = new AttendanceSheet
|
||||
{
|
||||
TeachingTaskId = request.TeachingTaskId,
|
||||
Name = request.Name.Trim(),
|
||||
AttendanceDate = request.AttendanceDate,
|
||||
CheckInMethod = checkInMethod,
|
||||
CheckInToken = checkInToken,
|
||||
CheckInStartsAt = checkInMethod == AttendanceCheckInMethod.Manual
|
||||
? null
|
||||
: now,
|
||||
CheckInEndsAt = checkInEndsAt,
|
||||
TargetLatitude = checkInMethod == AttendanceCheckInMethod.Location
|
||||
? request.TargetLatitude
|
||||
: null,
|
||||
TargetLongitude = checkInMethod == AttendanceCheckInMethod.Location
|
||||
? request.TargetLongitude
|
||||
: null,
|
||||
LocationRadiusMeters = checkInMethod == AttendanceCheckInMethod.Location
|
||||
? request.LocationRadiusMeters
|
||||
: null,
|
||||
Notes = Normalize(request.Notes),
|
||||
Records = studentIds.Select(studentId => new AttendanceRecord
|
||||
{
|
||||
StudentId = studentId
|
||||
StudentId = studentId,
|
||||
Status = checkInMethod == AttendanceCheckInMethod.Manual
|
||||
? AttendanceStatus.Present
|
||||
: AttendanceStatus.Absent
|
||||
}).ToList()
|
||||
};
|
||||
db.AttendanceSheets.Add(sheet);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Created(string.Empty, new { sheet.Id });
|
||||
return Created(string.Empty, new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInToken,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
@@ -138,6 +194,13 @@ public sealed class AttendanceController(
|
||||
x.Name,
|
||||
x.AttendanceDate,
|
||||
x.Status,
|
||||
x.CheckInMethod,
|
||||
x.CheckInToken,
|
||||
x.CheckInStartsAt,
|
||||
x.CheckInEndsAt,
|
||||
x.TargetLatitude,
|
||||
x.TargetLongitude,
|
||||
x.LocationRadiusMeters,
|
||||
x.Notes,
|
||||
x.SubmittedAt,
|
||||
TaskNumber = x.TeachingTask!.TaskNumber,
|
||||
@@ -153,7 +216,11 @@ public sealed class AttendanceController(
|
||||
r.Student.Name,
|
||||
ClassName = r.Student.AdministrativeClass!.Name,
|
||||
r.Status,
|
||||
r.Notes
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters
|
||||
})
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
@@ -170,21 +237,52 @@ public sealed class AttendanceController(
|
||||
.Where(e => e.TeachingTaskId == sheet.TeachingTaskId && e.Status == ApprovalStatus.Approved)
|
||||
.Select(e => e.StudentId).ToHashSetAsync(cancellationToken);
|
||||
|
||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft &&
|
||||
await CanManageTaskAsync(
|
||||
var canManage = await CanManageTaskAsync(
|
||||
sheet.TeachingTaskId,
|
||||
cancellationToken);
|
||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = new
|
||||
{
|
||||
sheet.Id, sheet.TeachingTaskId, sheet.Name, sheet.AttendanceDate,
|
||||
sheet.Status, sheet.Notes, sheet.SubmittedAt,
|
||||
sheet.TaskNumber, sheet.TaskName, sheet.CourseCode, sheet.CourseName,
|
||||
sheet.Id,
|
||||
sheet.TeachingTaskId,
|
||||
sheet.Name,
|
||||
sheet.AttendanceDate,
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
sheet.TargetLatitude,
|
||||
sheet.TargetLongitude,
|
||||
sheet.LocationRadiusMeters,
|
||||
IsCheckInOpen = IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now),
|
||||
CheckedInCount = sheet.Records.Count(r => r.CheckInAt != null),
|
||||
sheet.Notes,
|
||||
sheet.SubmittedAt,
|
||||
sheet.TaskNumber,
|
||||
sheet.TaskName,
|
||||
sheet.CourseCode,
|
||||
sheet.CourseName,
|
||||
Records = sheet.Records.Select(r => new
|
||||
{
|
||||
r.StudentId, r.StudentNumber, r.Name, r.ClassName,
|
||||
r.Status, r.Notes,
|
||||
r.StudentId,
|
||||
r.StudentNumber,
|
||||
r.Name,
|
||||
r.ClassName,
|
||||
r.Status,
|
||||
r.Notes,
|
||||
r.CheckInAt,
|
||||
r.CheckedInMethod,
|
||||
r.CheckInAccuracyMeters,
|
||||
r.CheckInDistanceMeters,
|
||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
||||
})
|
||||
@@ -375,8 +473,231 @@ public sealed class AttendanceController(
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/close-check-in")]
|
||||
[Authorize(Roles = AttendanceRoles)]
|
||||
public async Task<ActionResult> CloseCheckIn(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await db.AttendanceSheets
|
||||
.Include(x => x.TeachingTask)
|
||||
.ThenInclude(x => x!.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanManageSheet(sheet)) return Forbid();
|
||||
if (sheet.Status != AttendanceSheetStatus.Draft)
|
||||
return ConflictProblem("考勤表已提交,签到活动已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("普通点名没有在线签到活动。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (sheet.CheckInEndsAt is null || sheet.CheckInEndsAt > now)
|
||||
sheet.CheckInEndsAt = now;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ═══════════════ Student endpoints ═══════════════
|
||||
|
||||
[HttpGet("check-in-info")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetCheckInInfo(
|
||||
[FromQuery, MaxLength(64)] string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return NotFound();
|
||||
|
||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
SheetName = x.AttendanceSheet!.Name,
|
||||
x.AttendanceSheet.AttendanceDate,
|
||||
x.AttendanceSheet.Status,
|
||||
x.AttendanceSheet.CheckInMethod,
|
||||
x.AttendanceSheet.CheckInStartsAt,
|
||||
x.AttendanceSheet.CheckInEndsAt,
|
||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||
x.CheckInAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (activity is null) return NotFound();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(new
|
||||
{
|
||||
activity.SheetId,
|
||||
activity.SheetName,
|
||||
activity.AttendanceDate,
|
||||
activity.CheckInMethod,
|
||||
activity.CheckInStartsAt,
|
||||
activity.CheckInEndsAt,
|
||||
activity.CourseCode,
|
||||
activity.CourseName,
|
||||
activity.TaskNumber,
|
||||
activity.CheckInAt,
|
||||
IsOpen = IsCheckInOpen(
|
||||
activity.Status,
|
||||
activity.CheckInMethod,
|
||||
activity.CheckInStartsAt,
|
||||
activity.CheckInEndsAt,
|
||||
now)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("open-check-ins")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetOpenCheckIns(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(await db.AttendanceRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == studentId.Value &&
|
||||
x.AttendanceSheet!.Status == AttendanceSheetStatus.Draft &&
|
||||
x.AttendanceSheet.CheckInMethod == AttendanceCheckInMethod.Location &&
|
||||
x.AttendanceSheet.CheckInStartsAt <= now &&
|
||||
x.AttendanceSheet.CheckInEndsAt >= now)
|
||||
.OrderBy(x => x.AttendanceSheet!.CheckInEndsAt)
|
||||
.Select(x => new
|
||||
{
|
||||
SheetId = x.AttendanceSheetId,
|
||||
SheetName = x.AttendanceSheet!.Name,
|
||||
x.AttendanceSheet.AttendanceDate,
|
||||
x.AttendanceSheet.CheckInMethod,
|
||||
x.AttendanceSheet.CheckInStartsAt,
|
||||
x.AttendanceSheet.CheckInEndsAt,
|
||||
x.AttendanceSheet.LocationRadiusMeters,
|
||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||
x.CheckInAt
|
||||
})
|
||||
.ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> CheckIn(
|
||||
AttendanceCheckInRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var studentId = await GetCurrentStudentIdAsync(cancellationToken);
|
||||
if (!studentId.HasValue)
|
||||
return ConflictProblem("当前账号未关联学生档案。");
|
||||
|
||||
var source = db.AttendanceRecords
|
||||
.Include(x => x.AttendanceSheet)
|
||||
.Where(x => x.StudentId == studentId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
var token = request.Token.Trim();
|
||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
||||
}
|
||||
else if (request.AttendanceSheetId.HasValue)
|
||||
{
|
||||
source = source.Where(x =>
|
||||
x.AttendanceSheetId == request.AttendanceSheetId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ConflictProblem("缺少签到活动信息。");
|
||||
}
|
||||
|
||||
var record = await source.FirstOrDefaultAsync(cancellationToken);
|
||||
if (record?.AttendanceSheet is null) return NotFound();
|
||||
var sheet = record.AttendanceSheet;
|
||||
var now = DateTime.UtcNow;
|
||||
if (record.CheckInAt.HasValue)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
AlreadyCheckedIn = true,
|
||||
record.CheckInAt,
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
if (!IsCheckInOpen(
|
||||
sheet.Status,
|
||||
sheet.CheckInMethod,
|
||||
sheet.CheckInStartsAt,
|
||||
sheet.CheckInEndsAt,
|
||||
now))
|
||||
return ConflictProblem("签到尚未开始或已经结束。");
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
||||
|
||||
double? distanceMeters = null;
|
||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
||||
!string.Equals(
|
||||
sheet.CheckInToken,
|
||||
request.Token.Trim(),
|
||||
StringComparison.Ordinal))
|
||||
return NotFound();
|
||||
}
|
||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||
{
|
||||
if (request.Latitude is < -90 or > 90 ||
|
||||
request.Longitude is < -180 or > 180 ||
|
||||
request.Latitude is null ||
|
||||
request.Longitude is null)
|
||||
return ConflictProblem("未获取到有效的当前位置。");
|
||||
if (sheet.TargetLatitude is null ||
|
||||
sheet.TargetLongitude is null ||
|
||||
sheet.LocationRadiusMeters is null)
|
||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
||||
|
||||
distanceMeters = CalculateDistanceMeters(
|
||||
(double)sheet.TargetLatitude.Value,
|
||||
(double)sheet.TargetLongitude.Value,
|
||||
(double)request.Latitude.Value,
|
||||
(double)request.Longitude.Value);
|
||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||
{
|
||||
return ConflictProblem(
|
||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
||||
}
|
||||
}
|
||||
|
||||
record.Status = AttendanceStatus.Present;
|
||||
record.CheckInAt = now;
|
||||
record.CheckedInMethod = sheet.CheckInMethod;
|
||||
record.CheckInLatitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
||||
? request.Latitude
|
||||
: null;
|
||||
record.CheckInLongitude = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
||||
? request.Longitude
|
||||
: null;
|
||||
record.CheckInAccuracyMeters = sheet.CheckInMethod == AttendanceCheckInMethod.Location
|
||||
? request.AccuracyMeters
|
||||
: null;
|
||||
record.CheckInDistanceMeters = distanceMeters;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
AlreadyCheckedIn = false,
|
||||
record.CheckInAt,
|
||||
record.CheckInDistanceMeters
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("my-records")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetMyRecords(
|
||||
@@ -403,6 +724,7 @@ public sealed class AttendanceController(
|
||||
.Select(r => new
|
||||
{
|
||||
r.AttendanceSheetId,
|
||||
r.AttendanceSheet!.TeachingTaskId,
|
||||
SheetName = r.AttendanceSheet!.Name,
|
||||
r.AttendanceSheet.AttendanceDate,
|
||||
TaskNumber = r.AttendanceSheet.TeachingTask!.TaskNumber,
|
||||
@@ -504,8 +826,8 @@ public sealed class AttendanceController(
|
||||
|
||||
var source = db.AttendanceRecords.AsNoTracking()
|
||||
.Where(r =>
|
||||
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted &&
|
||||
targetClassIds.Contains(r.Student!.AdministrativeClassId));
|
||||
r.AttendanceSheet!.Status == AttendanceSheetStatus.Submitted)
|
||||
.WhereIn(targetClassIds, r => r.Student!.AdministrativeClassId);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(r =>
|
||||
r.AttendanceSheet!.TeachingTask!.AcademicTermId == academicTermId);
|
||||
@@ -648,6 +970,51 @@ public sealed class AttendanceController(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Guid?> GetCurrentStudentIdAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
return await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => (Guid?)x.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsCheckInOpen(
|
||||
AttendanceSheetStatus status,
|
||||
AttendanceCheckInMethod method,
|
||||
DateTime? startsAt,
|
||||
DateTime? endsAt,
|
||||
DateTime now) =>
|
||||
status == AttendanceSheetStatus.Draft &&
|
||||
method != AttendanceCheckInMethod.Manual &&
|
||||
startsAt.HasValue &&
|
||||
endsAt.HasValue &&
|
||||
startsAt.Value <= now &&
|
||||
endsAt.Value >= now;
|
||||
|
||||
internal static double CalculateDistanceMeters(
|
||||
double latitude1,
|
||||
double longitude1,
|
||||
double latitude2,
|
||||
double longitude2)
|
||||
{
|
||||
const double earthRadiusMeters = 6_371_000;
|
||||
var latitudeDelta = DegreesToRadians(latitude2 - latitude1);
|
||||
var longitudeDelta = DegreesToRadians(longitude2 - longitude1);
|
||||
var startLatitude = DegreesToRadians(latitude1);
|
||||
var endLatitude = DegreesToRadians(latitude2);
|
||||
var haversine =
|
||||
Math.Sin(latitudeDelta / 2) * Math.Sin(latitudeDelta / 2) +
|
||||
Math.Cos(startLatitude) * Math.Cos(endLatitude) *
|
||||
Math.Sin(longitudeDelta / 2) * Math.Sin(longitudeDelta / 2);
|
||||
return earthRadiusMeters * 2 *
|
||||
Math.Atan2(Math.Sqrt(haversine), Math.Sqrt(1 - haversine));
|
||||
}
|
||||
|
||||
private static double DegreesToRadians(double degrees) =>
|
||||
degrees * Math.PI / 180;
|
||||
|
||||
private async Task<AttendanceCourseStatistics?> LoadTaskStatisticsAsync(
|
||||
Guid teachingTaskId,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -981,7 +1348,12 @@ public sealed record AttendanceSheetRequest(
|
||||
Guid TeachingTaskId,
|
||||
[MaxLength(120)] string Name,
|
||||
DateTime AttendanceDate,
|
||||
[MaxLength(500)] string? Notes);
|
||||
[MaxLength(500)] string? Notes,
|
||||
AttendanceCheckInMethod? CheckInMethod,
|
||||
[Range(1, 180)] int? CheckInDurationMinutes,
|
||||
[Range(-90, 90)] decimal? TargetLatitude,
|
||||
[Range(-180, 180)] decimal? TargetLongitude,
|
||||
[Range(20, 1000)] int? LocationRadiusMeters);
|
||||
|
||||
public sealed record AttendanceRecordsRequest(
|
||||
IReadOnlyCollection<AttendanceRecordRequest> Records);
|
||||
@@ -999,6 +1371,13 @@ public sealed record AttendanceAppealReviewRequest(
|
||||
bool Approve,
|
||||
[MaxLength(300)] string? Comment);
|
||||
|
||||
public sealed record AttendanceCheckInRequest(
|
||||
Guid? AttendanceSheetId,
|
||||
[MaxLength(64)] string? Token,
|
||||
[Range(-90, 90)] decimal? Latitude,
|
||||
[Range(-180, 180)] decimal? Longitude,
|
||||
[Range(0, 5000)] double? AccuracyMeters);
|
||||
|
||||
public sealed record AttendanceCourseStatistics(
|
||||
AttendanceStatisticsCourse Course,
|
||||
AttendanceStatisticsSummary Summary,
|
||||
|
||||
@@ -57,6 +57,9 @@ public sealed class AuthController(
|
||||
{
|
||||
var name = request.Name.Trim();
|
||||
var studentNumber = request.StudentNumber.Trim();
|
||||
var executionStrategy = db.Database.CreateExecutionStrategy();
|
||||
return await executionStrategy.ExecuteAsync<ActionResult>(async () =>
|
||||
{
|
||||
var student = await db.Students
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Major)
|
||||
@@ -83,7 +86,8 @@ public sealed class AuthController(
|
||||
"该学号已有登录账号但未正确关联,请联系管理员处理。",
|
||||
StatusCodes.Status409Conflict);
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = studentNumber,
|
||||
@@ -110,6 +114,7 @@ public sealed class AuthController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { UserName = studentNumber });
|
||||
});
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
|
||||
@@ -287,7 +287,9 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
public async Task<ActionResult<IReadOnlyCollection<AcademicTerm>>> GetTerms(
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.AcademicTerms.AsNoTracking()
|
||||
.OrderByDescending(x => x.StartDate)
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
[HttpPost("terms")]
|
||||
@@ -298,11 +300,20 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
if (request.EndDate <= request.StartDate)
|
||||
return ValidationProblem("学期结束日期必须晚于开始日期。");
|
||||
if (request.IsCurrent && !request.IsEnabled)
|
||||
return ValidationProblem("当前学期必须保持启用。");
|
||||
|
||||
if (request.IsCurrent)
|
||||
await db.AcademicTerms.ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(x => x.IsCurrent, false),
|
||||
cancellationToken);
|
||||
var hasCurrentTerm = await db.AcademicTerms
|
||||
.AnyAsync(x => x.IsCurrent, cancellationToken);
|
||||
var shouldBeCurrent = request.IsCurrent || (!hasCurrentTerm && request.IsEnabled);
|
||||
if (shouldBeCurrent)
|
||||
{
|
||||
var currentTerms = await db.AcademicTerms
|
||||
.Where(x => x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
}
|
||||
|
||||
var entity = new AcademicTerm
|
||||
{
|
||||
@@ -312,7 +323,7 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
Season = request.Season,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
IsCurrent = request.IsCurrent,
|
||||
IsCurrent = shouldBeCurrent,
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
return await CreateAsync(entity, "GetTerms", cancellationToken);
|
||||
@@ -327,10 +338,22 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (request.EndDate <= request.StartDate)
|
||||
return ValidationProblem("学期结束日期必须晚于开始日期。");
|
||||
if (entity.IsCurrent && !request.IsCurrent)
|
||||
return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。");
|
||||
if (request.IsCurrent && entity.IsArchived)
|
||||
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
|
||||
if (request.IsCurrent && !request.IsEnabled)
|
||||
return ValidationProblem("当前学期必须保持启用。");
|
||||
if (request.IsCurrent)
|
||||
await db.AcademicTerms.Where(x => x.Id != id).ExecuteUpdateAsync(
|
||||
setters => setters.SetProperty(x => x.IsCurrent, false),
|
||||
cancellationToken);
|
||||
{
|
||||
var currentTerms = await db.AcademicTerms
|
||||
.Where(x => x.Id != id && x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
}
|
||||
ApplyCatalog(entity, request);
|
||||
entity.AcademicYear = request.AcademicYear.Trim();
|
||||
entity.Season = request.Season;
|
||||
@@ -341,6 +364,64 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpPost("terms/{id:guid}/set-current")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<AcademicTerm>> SetCurrentTerm(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (entity.IsArchived)
|
||||
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
|
||||
if (!entity.IsEnabled)
|
||||
return ConflictProblem("停用学期不能设为当前,请先启用。");
|
||||
if (entity.IsCurrent) return entity;
|
||||
|
||||
var currentTerms = await db.AcademicTerms
|
||||
.Where(x => x.Id != id && x.IsCurrent)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var currentTerm in currentTerms)
|
||||
currentTerm.IsCurrent = false;
|
||||
entity.IsCurrent = true;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpPost("terms/{id:guid}/archive")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<AcademicTerm>> ArchiveTerm(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (entity.IsCurrent)
|
||||
return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。");
|
||||
if (entity.IsArchived) return entity;
|
||||
|
||||
entity.IsArchived = true;
|
||||
entity.ArchivedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpPost("terms/{id:guid}/unarchive")]
|
||||
[Authorize(Roles = Administrators)]
|
||||
public async Task<ActionResult<AcademicTerm>> UnarchiveTerm(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
|
||||
if (entity is null) return NotFound();
|
||||
if (!entity.IsArchived) return entity;
|
||||
|
||||
entity.IsArchived = false;
|
||||
entity.ArchivedAt = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[HttpGet("classrooms")]
|
||||
public async Task<ActionResult<object>> GetClassrooms(CancellationToken cancellationToken) =>
|
||||
Ok(await db.Classrooms.AsNoTracking()
|
||||
@@ -447,6 +528,8 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
};
|
||||
|
||||
if (entity is null) return NotFound();
|
||||
if (entity is AcademicTerm { IsCurrent: true })
|
||||
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
|
||||
db.Remove(entity);
|
||||
try
|
||||
{
|
||||
@@ -508,6 +591,14 @@ public sealed class BaseDataController(AppDbContext db) : ControllerBase
|
||||
entity.SortOrder = request.SortOrder;
|
||||
entity.IsEnabled = request.IsEnabled;
|
||||
}
|
||||
|
||||
private ObjectResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "当前操作无法完成",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
|
||||
public record CatalogRequest(
|
||||
|
||||
@@ -77,20 +77,30 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
var errors = new List<string>();
|
||||
ExcelImportResult result;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
result = kind.ToLowerInvariant() switch
|
||||
{
|
||||
"campuses" => await ImportCampusesAsync(rows, errors, cancellationToken),
|
||||
"colleges" => await ImportCollegesAsync(rows, errors, cancellationToken),
|
||||
"majors" => await ImportMajorsAsync(rows, errors, cancellationToken),
|
||||
"classes" => await ImportClassesAsync(rows, errors, cancellationToken),
|
||||
"terms" => await ImportTermsAsync(rows, errors, cancellationToken),
|
||||
"buildings" => await ImportBuildingsAsync(rows, errors, cancellationToken),
|
||||
"classrooms" => await ImportClassroomsAsync(rows, errors, cancellationToken),
|
||||
"campuses" => await ImportCampusesAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"colleges" => await ImportCollegesAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"majors" => await ImportMajorsAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"classes" => await ImportClassesAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"terms" => await ImportTermsAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"buildings" => await ImportBuildingsAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"classrooms" => await ImportClassroomsAsync(
|
||||
rows, errors, cancellationToken),
|
||||
"course-categories" => await ImportCourseCategoriesAsync(
|
||||
rows, errors, cancellationToken),
|
||||
_ => throw new InvalidOperationException()
|
||||
@@ -116,6 +126,8 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
|
||||
@@ -256,7 +268,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new Major
|
||||
{
|
||||
Code = code, Name = name, CollegeId = college.Id,
|
||||
Code = code,
|
||||
Name = name,
|
||||
CollegeId = college.Id,
|
||||
DegreeType = degreeType
|
||||
};
|
||||
db.Majors.Add(entity);
|
||||
@@ -316,7 +330,10 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new AdministrativeClass
|
||||
{
|
||||
Code = code, Name = name, MajorId = major.Id, Grade = grade.Value
|
||||
Code = code,
|
||||
Name = name,
|
||||
MajorId = major.Id,
|
||||
Grade = grade.Value
|
||||
};
|
||||
db.AdministrativeClasses.Add(entity);
|
||||
existing[code] = entity;
|
||||
@@ -362,8 +379,11 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new AcademicTerm
|
||||
{
|
||||
Code = code, Name = name, AcademicYear = academicYear,
|
||||
Season = season.Value, StartDate = startDate.Value,
|
||||
Code = code,
|
||||
Name = name,
|
||||
AcademicYear = academicYear,
|
||||
Season = season.Value,
|
||||
StartDate = startDate.Value,
|
||||
EndDate = endDate.Value
|
||||
};
|
||||
db.AcademicTerms.Add(entity);
|
||||
@@ -461,7 +481,9 @@ public sealed class BaseDataExcelController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
entity = new Classroom
|
||||
{
|
||||
Code = code, Name = name, BuildingId = building.Id,
|
||||
Code = code,
|
||||
Name = name,
|
||||
BuildingId = building.Id,
|
||||
RoomType = roomType
|
||||
};
|
||||
db.Classrooms.Add(entity);
|
||||
|
||||
@@ -58,6 +58,8 @@ public sealed class CourseSelectionsController(
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
x.WithdrawalEndsAt,
|
||||
@@ -369,12 +371,12 @@ public sealed class CourseSelectionsController(
|
||||
var source = db.Students.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == StudentStatus.Active &&
|
||||
(offering.IsOpenToAll ||
|
||||
offering.ClassIds.Contains(x.AdministrativeClassId)) &&
|
||||
!db.CourseEnrollments.Any(enrollment =>
|
||||
enrollment.CourseSelectionOfferingId == id &&
|
||||
enrollment.StudentId == x.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled));
|
||||
if (!offering.IsOpenToAll)
|
||||
source = source.WhereIn(offering.ClassIds, x => x.AdministrativeClassId);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
keyword = keyword.Trim();
|
||||
@@ -415,9 +417,10 @@ public sealed class CourseSelectionsController(
|
||||
if (studentIds.Length > 100)
|
||||
return ValidationProblem("单次最多可为 100 名学生代选。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -437,7 +440,7 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var students = await db.Students
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.Where(x => studentIds.Contains(x.Id))
|
||||
.WhereIn(studentIds, x => x.Id)
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (students.Count != studentIds.Length)
|
||||
@@ -456,9 +459,8 @@ public sealed class CourseSelectionsController(
|
||||
}
|
||||
|
||||
var existingEnrollments = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOfferingId == id &&
|
||||
studentIds.Contains(x.StudentId))
|
||||
.Where(x => x.CourseSelectionOfferingId == id)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var alreadyEnrolled = existingEnrollments.FirstOrDefault(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled);
|
||||
@@ -482,11 +484,11 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var duplicateStudentIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
studentIds.Contains(x.StudentId) &&
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTask!.CourseId == task.CourseId &&
|
||||
x.CourseSelectionOffering.CourseSelectionRound!.AcademicTermId ==
|
||||
round.AcademicTermId)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -566,6 +568,9 @@ public sealed class CourseSelectionsController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { EnrolledCount = students.Count });
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
[HttpPost("offerings/{offeringId:guid}/force-enroll")]
|
||||
@@ -581,8 +586,10 @@ public sealed class CourseSelectionsController(
|
||||
if (studentIds.Length > 100)
|
||||
return ValidationProblem("单次最多可为 100 名学生强制选课。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable, cancellationToken);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -599,16 +606,15 @@ public sealed class CourseSelectionsController(
|
||||
|
||||
var students = await db.Students
|
||||
.Include(x => x.AdministrativeClass)
|
||||
.Where(x => studentIds.Contains(x.Id))
|
||||
.WhereIn(studentIds, x => x.Id)
|
||||
.OrderBy(x => x.StudentNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (students.Count != studentIds.Length)
|
||||
return ValidationProblem("存在无效的学生档案。");
|
||||
|
||||
var existingEnrollments = await db.CourseEnrollments
|
||||
.Where(x =>
|
||||
x.CourseSelectionOfferingId == offeringId &&
|
||||
studentIds.Contains(x.StudentId))
|
||||
.Where(x => x.CourseSelectionOfferingId == offeringId)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.ToListAsync(cancellationToken);
|
||||
var alreadyEnrolled = existingEnrollments
|
||||
.FirstOrDefault(x => x.Status == CourseEnrollmentStatus.Enrolled);
|
||||
@@ -648,6 +654,9 @@ public sealed class CourseSelectionsController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { EnrolledCount = enrolled });
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
[HttpDelete("offerings/{offeringId:guid}/admin-enrollments/{enrollmentId:guid}")]
|
||||
@@ -824,14 +833,15 @@ public sealed class CourseSelectionsController(
|
||||
StudentEnrollmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var student = await CurrentStudentAsync(cancellationToken);
|
||||
if (student is null) return ProfileNotFound();
|
||||
if (student.Status != StudentStatus.Active)
|
||||
return ConflictProblem("只有在籍学生可以选课。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
var offering = await db.CourseSelectionOfferings
|
||||
.Include(x => x.CourseSelectionRound)
|
||||
.Include(x => x.TeachingTask)
|
||||
@@ -963,6 +973,9 @@ public sealed class CourseSelectionsController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Created(string.Empty, new { existing.Id, IsRetake = isRetake });
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
[HttpDelete("student/enrollments/{id:guid}")]
|
||||
@@ -1159,9 +1172,9 @@ public sealed class CourseSelectionsController(
|
||||
if (taskIds.Count == 0) return [];
|
||||
return await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(x =>
|
||||
taskIds.Contains(x.TeachingTaskId) &&
|
||||
x.SchedulePlan!.AcademicTermId == academicTermId &&
|
||||
x.SchedulePlan.Status == SchedulePlanStatus.Published)
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,12 +122,17 @@ public sealed class CoursesExcelController(
|
||||
if (rows.Count == 0)
|
||||
return ValidationProblem("Excel 中没有可导入的课程数据。");
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
var errors = new List<string>();
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = await ImportRowsAsync(rows, errors, cancellationToken);
|
||||
var result = await ImportRowsAsync(
|
||||
rows,
|
||||
errors,
|
||||
cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
@@ -148,6 +153,8 @@ public sealed class CoursesExcelController(
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ExcelImportResult> ImportRowsAsync(
|
||||
|
||||
@@ -103,12 +103,14 @@ public sealed class CurriculumPlansController(
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var accessibleCollegeIds = majorOptions
|
||||
.Select(x => x.CollegeId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var accessibleMajors = db.Majors.AsNoTracking().Where(x => x.IsEnabled);
|
||||
if (collegeId.HasValue)
|
||||
accessibleMajors = accessibleMajors.Where(
|
||||
x => x.CollegeId == collegeId.Value);
|
||||
var colleges = await db.Colleges.AsNoTracking()
|
||||
.Where(x => x.IsEnabled && accessibleCollegeIds.Contains(x.Id))
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
accessibleMajors.Any(major => major.CollegeId == x.Id))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Code)
|
||||
.Select(x => new { x.Id, x.Code, x.Name })
|
||||
@@ -288,6 +290,10 @@ public sealed class CurriculumPlansController(
|
||||
[HttpPost("{id:guid}/publish")]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var plan = await ScopedPlans()
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Modules)
|
||||
@@ -297,20 +303,25 @@ public sealed class CurriculumPlansController(
|
||||
if (plan is null) return NotFound();
|
||||
if (plan.Status != CurriculumPlanStatus.Draft)
|
||||
return ConflictProblem("只有草稿方案可以发布。");
|
||||
if (plan.Modules.Count == 0 || plan.Modules.Any(x => x.Courses.Count == 0))
|
||||
if (plan.Modules.Count == 0 ||
|
||||
plan.Modules.Any(x => x.Courses.Count == 0))
|
||||
return ConflictProblem("发布前每个课程模块都必须配置课程。");
|
||||
if (plan.Modules.Sum(x => x.RequiredCredits) != plan.TotalCredits)
|
||||
return ConflictProblem("各模块最低学分之和必须等于方案总学分。");
|
||||
if (plan.Modules.Any(module =>
|
||||
module.Courses.Sum(x => x.Course!.Credits) < module.RequiredCredits))
|
||||
return ConflictProblem("存在课程学分合计低于最低学分要求的模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).GroupBy(x => x.CourseId).Any(x => x.Count() > 1))
|
||||
module.Courses.Sum(x => x.Course!.Credits) <
|
||||
module.RequiredCredits))
|
||||
return ConflictProblem(
|
||||
"存在课程学分合计低于最低学分要求的模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses)
|
||||
.GroupBy(x => x.CourseId)
|
||||
.Any(x => x.Count() > 1))
|
||||
return ConflictProblem("同一门课程不能重复加入多个模块。");
|
||||
if (plan.Modules.SelectMany(x => x.Courses).Any(x =>
|
||||
x.RecommendedSemester > plan.Major!.SchoolingYears * 2))
|
||||
x.RecommendedSemester >
|
||||
plan.Major!.SchoolingYears * 2))
|
||||
return ConflictProblem("建议学期超出了该专业学制。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var previousPlans = await ScopedPlans()
|
||||
.Where(x =>
|
||||
x.Id != plan.Id &&
|
||||
@@ -328,6 +339,8 @@ public sealed class CurriculumPlansController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{planId:guid}/modules")]
|
||||
|
||||
@@ -31,9 +31,15 @@ public sealed class DegreeAwardsController(
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.GraduationYear, x.DegreeName,
|
||||
x.MinimumGradePoint, x.Status, x.Notes,
|
||||
x.CalculatedAt, x.PublishedAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.GraduationYear,
|
||||
x.DegreeName,
|
||||
x.MinimumGradePoint,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.CalculatedAt,
|
||||
x.PublishedAt,
|
||||
ResultCount = x.Results.Count(result =>
|
||||
!collegeId.HasValue ||
|
||||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
|
||||
@@ -67,19 +73,32 @@ public sealed class DegreeAwardsController(
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.DegreeName,
|
||||
batch.MinimumGradePoint, batch.Status, batch.Notes,
|
||||
batch.CalculatedAt, batch.PublishedAt,
|
||||
batch.Id,
|
||||
batch.Name,
|
||||
batch.GraduationYear,
|
||||
batch.DegreeName,
|
||||
batch.MinimumGradePoint,
|
||||
batch.Status,
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await source.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.AverageGradePoint, x.CalculatedConclusion, x.Conclusion,
|
||||
x.ExceptionReason, x.IsOverridden,
|
||||
x.ReviewComment, x.ReviewedAt
|
||||
x.AverageGradePoint,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
});
|
||||
}
|
||||
@@ -130,9 +149,9 @@ public sealed class DegreeAwardsController(
|
||||
.ToList();
|
||||
var studentIds = audits.Select(x => x.StudentId).ToArray();
|
||||
var gradePoints = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => studentIds.Contains(x.StudentId) &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published &&
|
||||
x.GradePoint.HasValue)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -241,10 +260,14 @@ public sealed class DegreeAwardsController(
|
||||
x.DegreeAwardBatch.GraduationYear,
|
||||
x.DegreeAwardBatch.DegreeName,
|
||||
x.DegreeAwardBatch.MinimumGradePoint,
|
||||
x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
MajorName = x.Student.AdministrativeClass!.Major!.Name,
|
||||
x.AverageGradePoint, x.Conclusion, x.ExceptionReason,
|
||||
x.IsOverridden, x.ReviewComment,
|
||||
x.AverageGradePoint,
|
||||
x.Conclusion,
|
||||
x.ExceptionReason,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.DegreeAwardBatch.PublishedAt
|
||||
}).FirstOrDefaultAsync(token);
|
||||
return Ok(result);
|
||||
|
||||
@@ -39,12 +39,21 @@ public sealed class EvaluationsController(
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.Status,
|
||||
x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.StartsAt, x.EndsAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.Status,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.StartsAt,
|
||||
x.EndsAt,
|
||||
Dimensions = x.Dimensions.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.MaxScore, d.SortOrder
|
||||
d.Id,
|
||||
d.Name,
|
||||
d.MaxScore,
|
||||
d.SortOrder
|
||||
}),
|
||||
RecordCount = x.Records.Count,
|
||||
x.CreatedAt
|
||||
@@ -194,8 +203,10 @@ public sealed class EvaluationsController(
|
||||
var enrollments = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(e =>
|
||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
e.StudentId == studentId.Value &&
|
||||
termIds.Contains(e.CourseSelectionOffering!.TeachingTask!.AcademicTermId))
|
||||
e.StudentId == studentId.Value)
|
||||
.WhereIn(
|
||||
termIds,
|
||||
e => e.CourseSelectionOffering!.TeachingTask!.AcademicTermId)
|
||||
.Select(e => new
|
||||
{
|
||||
e.CourseSelectionOffering!.TeachingTaskId,
|
||||
@@ -211,9 +222,10 @@ public sealed class EvaluationsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Check which tasks have already been evaluated
|
||||
var setupIds = openSetups.Select(s => s.Id).ToArray();
|
||||
var evaluatedIds = await db.EvaluationRecords.AsNoTracking()
|
||||
.Where(r => r.StudentId == studentId.Value &&
|
||||
openSetups.Select(s => s.Id).Contains(r.EvaluationSetupId))
|
||||
.Where(r => r.StudentId == studentId.Value)
|
||||
.WhereIn(setupIds, r => r.EvaluationSetupId)
|
||||
.Select(r => r.TeachingTaskId)
|
||||
.ToHashSetAsync(cancellationToken);
|
||||
|
||||
@@ -251,7 +263,9 @@ public sealed class EvaluationsController(
|
||||
.Where(x => x.Id == teachingTaskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.TaskNumber, x.Name,
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
@@ -283,7 +297,10 @@ public sealed class EvaluationsController(
|
||||
setup.Name,
|
||||
Dimensions = setup.Dimensions.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.MaxScore, d.SortOrder
|
||||
d.Id,
|
||||
d.Name,
|
||||
d.MaxScore,
|
||||
d.SortOrder
|
||||
}),
|
||||
Task = task
|
||||
});
|
||||
@@ -384,8 +401,8 @@ public sealed class EvaluationsController(
|
||||
foreach (var setup in setups)
|
||||
{
|
||||
var setupTaskIds = await db.EvaluationRecords.AsNoTracking()
|
||||
.Where(r => r.EvaluationSetupId == setup.Id &&
|
||||
taskIds.Contains(r.TeachingTaskId))
|
||||
.Where(r => r.EvaluationSetupId == setup.Id)
|
||||
.WhereIn(taskIds, r => r.TeachingTaskId)
|
||||
.Select(r => r.TeachingTaskId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -396,7 +413,9 @@ public sealed class EvaluationsController(
|
||||
.Where(x => x.Id == taskId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.TaskNumber, x.Name,
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name
|
||||
@@ -525,12 +544,18 @@ public sealed class EvaluationsController(
|
||||
{
|
||||
Setup = new
|
||||
{
|
||||
setup.Id, setup.Name, setup.Status,
|
||||
setup.Id,
|
||||
setup.Name,
|
||||
setup.Status,
|
||||
TermName = setup.AcademicTerm!.Name,
|
||||
setup.StartsAt, setup.EndsAt,
|
||||
setup.StartsAt,
|
||||
setup.EndsAt,
|
||||
Dimensions = setup.Dimensions.Select(d => new
|
||||
{
|
||||
d.Id, d.Name, d.MaxScore, d.SortOrder
|
||||
d.Id,
|
||||
d.Name,
|
||||
d.MaxScore,
|
||||
d.SortOrder
|
||||
})
|
||||
},
|
||||
ByTask = byTask,
|
||||
|
||||
@@ -39,8 +39,16 @@ public sealed class ExamsController(
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.Status,
|
||||
SessionCount = x.Sessions.Count,
|
||||
x.Notes,
|
||||
x.PublishedAt
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -72,8 +80,13 @@ public sealed class ExamsController(
|
||||
.Where(x => x.Id == id && (manager || x.Status == ExamPlanStatus.Published))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, x.Notes, x.PublishedAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.PublishedAt,
|
||||
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
|
||||
.ThenBy(item => item.StartPeriod).Select(item => new
|
||||
{
|
||||
@@ -270,7 +283,7 @@ public sealed class ExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (occupiedIds.Count > 0)
|
||||
query = query.Where(x => !occupiedIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedIds, x => x.Id);
|
||||
|
||||
return Ok(await query.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Capacity)
|
||||
@@ -313,8 +326,8 @@ public sealed class ExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyIds, x => x.Id)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -547,8 +560,10 @@ public sealed class ExamsController(
|
||||
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
||||
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
||||
if (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereIn(teacherIds, x => x.Id)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效监考教师。");
|
||||
}
|
||||
|
||||
@@ -564,9 +579,10 @@ public sealed class ExamsController(
|
||||
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await overlaps.AnyAsync(x =>
|
||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||
cancellationToken))
|
||||
if (await db.ExamSessionInvigilators
|
||||
.Where(i => overlaps.Any(x => x.Id == i.ExamSessionId))
|
||||
.WhereIn(teacherIds, i => i.TeacherId)
|
||||
.AnyAsync(cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有考试任务。");
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,15 @@ public sealed class GraduationAuditsController(
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.GraduationYear, x.EnrollmentYear, x.Status,
|
||||
x.Notes, x.CalculatedAt, x.PublishedAt, x.CreatedAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.GraduationYear,
|
||||
x.EnrollmentYear,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.CalculatedAt,
|
||||
x.PublishedAt,
|
||||
x.CreatedAt,
|
||||
ResultCount = x.Results.Count(result =>
|
||||
!collegeId.HasValue ||
|
||||
result.Student!.AdministrativeClass!.Major!.CollegeId == collegeId),
|
||||
@@ -68,23 +75,38 @@ public sealed class GraduationAuditsController(
|
||||
x.Student!.AdministrativeClass!.Major!.CollegeId == collegeId);
|
||||
return Ok(new
|
||||
{
|
||||
batch.Id, batch.Name, batch.GraduationYear, batch.EnrollmentYear,
|
||||
batch.Status, batch.Notes, batch.CalculatedAt, batch.PublishedAt,
|
||||
batch.Id,
|
||||
batch.Name,
|
||||
batch.GraduationYear,
|
||||
batch.EnrollmentYear,
|
||||
batch.Status,
|
||||
batch.Notes,
|
||||
batch.CalculatedAt,
|
||||
batch.PublishedAt,
|
||||
Results = await results
|
||||
.OrderBy(x => x.Student!.StudentNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.StudentId, x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Id,
|
||||
x.StudentId,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
ClassName = x.Student.AdministrativeClass!.Name,
|
||||
MajorName = x.Student.AdministrativeClass.Major!.Name,
|
||||
CollegeName = x.Student.AdministrativeClass.Major.College!.Name,
|
||||
x.StudentStatusSnapshot,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits, x.EarnedCredits,
|
||||
x.RequiredCourseCount, x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount, x.MissingCourseNames,
|
||||
x.CalculatedConclusion, x.Conclusion, x.IsOverridden,
|
||||
x.ReviewComment, x.ReviewedAt
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.CalculatedConclusion,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.ReviewedAt
|
||||
}).ToListAsync(token)
|
||||
});
|
||||
}
|
||||
@@ -136,8 +158,8 @@ public sealed class GraduationAuditsController(
|
||||
.ToListAsync(token);
|
||||
var studentIds = students.Select(x => x.Id).ToArray();
|
||||
var grades = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => studentIds.Contains(x.StudentId) &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.WhereIn(studentIds, x => x.StudentId)
|
||||
.Select(x => new GradeSnapshot(
|
||||
x.StudentId,
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
@@ -275,13 +297,19 @@ public sealed class GraduationAuditsController(
|
||||
x.Id,
|
||||
BatchName = x.GraduationAuditBatch!.Name,
|
||||
x.GraduationAuditBatch.GraduationYear,
|
||||
x.Student!.StudentNumber, x.Student.Name,
|
||||
x.Student!.StudentNumber,
|
||||
x.Student.Name,
|
||||
MajorName = x.Student.AdministrativeClass!.Major!.Name,
|
||||
PlanName = x.CurriculumPlan != null ? x.CurriculumPlan.Name : null,
|
||||
x.RequiredCredits, x.EarnedCredits,
|
||||
x.RequiredCourseCount, x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount, x.MissingCourseNames,
|
||||
x.Conclusion, x.IsOverridden, x.ReviewComment,
|
||||
x.RequiredCredits,
|
||||
x.EarnedCredits,
|
||||
x.RequiredCourseCount,
|
||||
x.PassedRequiredCourseCount,
|
||||
x.FailedCourseCount,
|
||||
x.MissingCourseNames,
|
||||
x.Conclusion,
|
||||
x.IsOverridden,
|
||||
x.ReviewComment,
|
||||
x.GraduationAuditBatch.PublishedAt
|
||||
}).FirstOrDefaultAsync(token);
|
||||
return Ok(result);
|
||||
|
||||
@@ -44,8 +44,16 @@ public sealed class MakeupExamsController(
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, SessionCount = x.Sessions.Count, x.Notes, x.PublishedAt
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
TermIsCurrent = x.AcademicTerm.IsCurrent,
|
||||
TermIsArchived = x.AcademicTerm.IsArchived,
|
||||
x.Status,
|
||||
SessionCount = x.Sessions.Count,
|
||||
x.Notes,
|
||||
x.PublishedAt
|
||||
}).ToListAsync(cancellationToken));
|
||||
}
|
||||
|
||||
@@ -77,8 +85,13 @@ public sealed class MakeupExamsController(
|
||||
.Where(x => x.Id == id && (manager || x.Status == MakeupExamPlanStatus.Published))
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.Name, x.AcademicTermId, TermName = x.AcademicTerm!.Name,
|
||||
x.Status, x.Notes, x.PublishedAt,
|
||||
x.Id,
|
||||
x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
x.Status,
|
||||
x.Notes,
|
||||
x.PublishedAt,
|
||||
Sessions = x.Sessions.OrderBy(item => item.ExamDate)
|
||||
.ThenBy(item => item.StartPeriod).Select(item => new
|
||||
{
|
||||
@@ -311,8 +324,12 @@ public sealed class MakeupExamsController(
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null)
|
||||
return Ok(new { jobId = existing.Id, status = existing.Status.ToString(),
|
||||
message = "该计划已有正在执行的任务。" });
|
||||
return Ok(new
|
||||
{
|
||||
jobId = existing.Id,
|
||||
status = existing.Status.ToString(),
|
||||
message = "该计划已有正在执行的任务。"
|
||||
});
|
||||
|
||||
var job = new MakeupExamAutoJob
|
||||
{
|
||||
@@ -404,9 +421,9 @@ public sealed class MakeupExamsController(
|
||||
|
||||
// Check for time conflicts with other makeup sessions
|
||||
var conflictIds = await db.MakeupExamEnrollments.AsNoTracking()
|
||||
.Where(x => validIds.Contains(x.StudentId) &&
|
||||
x.MakeupExamSession!.StartsAt < session.EndsAt &&
|
||||
.Where(x => x.MakeupExamSession!.StartsAt < session.EndsAt &&
|
||||
session.StartsAt < x.MakeupExamSession.EndsAt)
|
||||
.WhereIn(validIds, x => x.StudentId)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -414,7 +431,7 @@ public sealed class MakeupExamsController(
|
||||
if (conflictIds.Count > 0)
|
||||
{
|
||||
var conflictNumbers = await db.Students
|
||||
.Where(s => conflictIds.Contains(s.Id))
|
||||
.WhereIn(conflictIds, s => s.Id)
|
||||
.Select(s => s.StudentNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ConflictProblem(
|
||||
@@ -590,7 +607,7 @@ public sealed class MakeupExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (occupiedIds.Count > 0)
|
||||
query = query.Where(x => !occupiedIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedIds, x => x.Id);
|
||||
|
||||
return Ok(await query.OrderBy(x => x.Building!.Name)
|
||||
.ThenBy(x => x.Capacity)
|
||||
@@ -634,8 +651,8 @@ public sealed class MakeupExamsController(
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyIds, x => x.Id)
|
||||
.OrderBy(x => x.Name)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -845,8 +862,10 @@ public sealed class MakeupExamsController(
|
||||
var teacherIds = (invigilatorIds ?? []).Distinct().ToArray();
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await db.Teachers.CountAsync(x => teacherIds.Contains(x.Id) &&
|
||||
x.Status == TeacherStatus.Active, cancellationToken) != teacherIds.Length)
|
||||
if (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereIn(teacherIds, x => x.Id)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效监考教师。");
|
||||
}
|
||||
|
||||
@@ -862,9 +881,10 @@ public sealed class MakeupExamsController(
|
||||
|
||||
if (teacherIds.Length > 0)
|
||||
{
|
||||
if (await overlaps.AnyAsync(x =>
|
||||
x.Invigilators.Any(i => teacherIds.Contains(i.TeacherId)),
|
||||
cancellationToken))
|
||||
if (await db.MakeupExamSessionInvigilators
|
||||
.Where(i => overlaps.Any(x => x.Id == i.MakeupExamSessionId))
|
||||
.WhereIn(teacherIds, i => i.TeacherId)
|
||||
.AnyAsync(cancellationToken))
|
||||
return ConflictProblem("监考教师在该时段已有补考任务。");
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,10 @@ public sealed class PersonnelController(
|
||||
TeacherAccountActivationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var teacher = await db.Teachers.FindAsync([id], cancellationToken);
|
||||
if (teacher is null) return NotFound();
|
||||
if (!CanAccessCollege(teacher.CollegeId)) return Forbid();
|
||||
@@ -173,10 +177,10 @@ public sealed class PersonnelController(
|
||||
var userName = teacher.TeacherNumber.Trim();
|
||||
if (await userManager.FindByNameAsync(userName) is not null)
|
||||
{
|
||||
return ConflictProblem("该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
||||
return ConflictProblem(
|
||||
"该工号已有登录账号但未正确关联,请到账号管理中核对。");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = userName,
|
||||
@@ -193,7 +197,9 @@ public sealed class PersonnelController(
|
||||
return IdentityValidationProblem(result);
|
||||
}
|
||||
|
||||
result = await userManager.AddToRoleAsync(user, SystemRoles.Teacher);
|
||||
result = await userManager.AddToRoleAsync(
|
||||
user,
|
||||
SystemRoles.Teacher);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
@@ -204,6 +210,8 @@ public sealed class PersonnelController(
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new { user.Id, UserName = userName });
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("students")]
|
||||
|
||||
@@ -123,11 +123,16 @@ public sealed class PersonnelExcelController(
|
||||
}
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的数据。");
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
var errors = new List<string>();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var result = kind.Equals("teachers", StringComparison.OrdinalIgnoreCase)
|
||||
var result = kind.Equals(
|
||||
"teachers",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? await ImportTeachersAsync(rows, errors, cancellationToken)
|
||||
: await ImportStudentsAsync(rows, errors, cancellationToken);
|
||||
if (errors.Count > 0)
|
||||
@@ -149,6 +154,8 @@ public sealed class PersonnelExcelController(
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<ExcelImportResult> ImportTeachersAsync(
|
||||
|
||||
@@ -99,7 +99,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
.ToListAsync(cancellationToken);
|
||||
var taskIds = tasks.Select(x => x.Id).ToList();
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
return Ok(tasks.Select(task =>
|
||||
@@ -189,7 +189,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
return ValidationProblem("指定校区不存在或已停用。");
|
||||
|
||||
var allowedRooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => request.AllowedClassroomIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Where(x => x.IsEnabled)
|
||||
.WhereIn(request.AllowedClassroomIds, x => x.Id)
|
||||
.Include(x => x.Building)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedRooms.Count != request.AllowedClassroomIds.Distinct().Count())
|
||||
@@ -259,9 +260,9 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
|
||||
var tasks = await db.TeachingTasks
|
||||
.Where(x =>
|
||||
taskIds.Contains(x.Id) &&
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.Status == TeachingTaskStatus.Published)
|
||||
.WhereIn(taskIds, x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tasks.Count != taskIds.Length)
|
||||
return ValidationProblem("部分教学任务不存在、未发布或不属于当前学期。");
|
||||
@@ -297,7 +298,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
|
||||
var roomIds = request.AllowedClassroomIds?.Distinct().ToArray() ?? [];
|
||||
allowedRooms = await db.Classrooms.AsNoTracking()
|
||||
.Where(x => roomIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Where(x => x.IsEnabled)
|
||||
.WhereIn(roomIds, x => x.Id)
|
||||
.Include(x => x.Building)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (allowedRooms.Count != roomIds.Length)
|
||||
@@ -312,7 +314,7 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
var constraints = await db.TeachingTaskScheduleConstraints
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
foreach (var task in tasks)
|
||||
@@ -384,7 +386,8 @@ public sealed class ScheduleSettingsController(AppDbContext db) : ControllerBase
|
||||
IReadOnlyCollection<Guid> taskIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
db.ScheduleEntries.AsNoTracking()
|
||||
.AnyAsync(x => taskIds.Contains(x.TeachingTaskId), cancellationToken);
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.AnyAsync(cancellationToken);
|
||||
|
||||
private void ClearConstraint(TeachingTaskScheduleConstraint constraint)
|
||||
{
|
||||
|
||||
@@ -95,7 +95,12 @@ public sealed class StatisticsController(
|
||||
|
||||
return new
|
||||
{
|
||||
byCollege, byMajor, byClass, byGrade, byStatus, byGender,
|
||||
byCollege,
|
||||
byMajor,
|
||||
byClass,
|
||||
byGrade,
|
||||
byStatus,
|
||||
byGender,
|
||||
enrollmentTrend,
|
||||
totals = new { totalStudents }
|
||||
};
|
||||
@@ -255,7 +260,8 @@ public sealed class StatisticsController(
|
||||
|
||||
var passRateByCollegeResult = passRateByCollege.Select(x => new
|
||||
{
|
||||
x.collegeName, x.totalRecords,
|
||||
x.collegeName,
|
||||
x.totalRecords,
|
||||
passRate = x.totalRecords > 0 ? Math.Round((double)x.passedRecords / x.totalRecords, 4) : 0,
|
||||
averageScore = Math.Round(x.averageScore, 1)
|
||||
}).ToList();
|
||||
@@ -336,7 +342,8 @@ public sealed class StatisticsController(
|
||||
|
||||
var byCollegeResult = byCollege.Select(x => new
|
||||
{
|
||||
x.collegeName, x.total,
|
||||
x.collegeName,
|
||||
x.total,
|
||||
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
|
||||
}).OrderByDescending(x => x.passRate).ToList();
|
||||
|
||||
@@ -353,7 +360,9 @@ public sealed class StatisticsController(
|
||||
|
||||
var byCourseResult = byCourse.Select(x => new
|
||||
{
|
||||
x.courseCode, x.courseName, x.total,
|
||||
x.courseCode,
|
||||
x.courseName,
|
||||
x.total,
|
||||
passRate = x.total > 0 ? Math.Round((double)x.passed / x.total, 4) : 0
|
||||
}).OrderByDescending(x => x.passRate).ToList();
|
||||
|
||||
@@ -567,7 +576,8 @@ public sealed class StatisticsController(
|
||||
// get entries
|
||||
var entries = await db.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.SchedulePlanId == plan.Id)
|
||||
.Where(e => e.ClassroomId != null && classroomIds.Contains(e.ClassroomId.Value))
|
||||
.Where(e => e.ClassroomId != null)
|
||||
.WhereIn(classroomIds, e => e.ClassroomId!.Value)
|
||||
.Select(e => new
|
||||
{
|
||||
e.ClassroomId,
|
||||
@@ -633,7 +643,8 @@ public sealed class StatisticsController(
|
||||
var used = entries.Where(e => e.DayOfWeek == day).Sum(e => e.PeriodCount);
|
||||
return new
|
||||
{
|
||||
day, dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
|
||||
day,
|
||||
dayLabel = day < dayLabels.Length ? dayLabels[day] : $"周{day}",
|
||||
utilizationRate = dailyAvailable > 0 ? Math.Round((double)used / dailyAvailable, 4) : 0
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -74,8 +74,8 @@ public sealed class StudentCurriculumController(
|
||||
var gradeAttempts = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
planCourseIds.Contains(x.GradeSheet!.TeachingTask!.CourseId) &&
|
||||
x.GradeSheet.Status == GradeSheetStatus.Published)
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published)
|
||||
.WhereIn(planCourseIds, x => x.GradeSheet!.TeachingTask!.CourseId)
|
||||
.Select(x => new StudentGradeAttempt(
|
||||
x.GradeSheet!.TeachingTask!.CourseId,
|
||||
x.TotalScore,
|
||||
@@ -88,7 +88,6 @@ public sealed class StudentCurriculumController(
|
||||
|
||||
var currentCourseIds = await db.TeachingTasks.AsNoTracking()
|
||||
.Where(task =>
|
||||
planCourseIds.Contains(task.CourseId) &&
|
||||
task.Status == TeachingTaskStatus.Published &&
|
||||
task.AcademicTerm!.IsCurrent &&
|
||||
(task.Classes.Any(item =>
|
||||
@@ -97,6 +96,7 @@ public sealed class StudentCurriculumController(
|
||||
enrollment.StudentId == student.Id &&
|
||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
enrollment.CourseSelectionOffering!.TeachingTaskId == task.Id)))
|
||||
.WhereIn(planCourseIds, task => task.CourseId)
|
||||
.Select(x => x.CourseId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -342,7 +342,7 @@ public sealed class TeachingTasksController(
|
||||
.Include(x => x.Classes)
|
||||
.ThenInclude(x => x.AdministrativeClass)
|
||||
.ThenInclude(x => x!.Students)
|
||||
.Where(x => ids.Contains(x.Id))
|
||||
.WhereIn(ids, x => x.Id)
|
||||
.OrderBy(x => x.TaskNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (tasks.Count != ids.Length)
|
||||
@@ -431,7 +431,8 @@ public sealed class TeachingTasksController(
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
if (classIds.Length == 0) return ValidationProblem("请至少选择一个行政班。");
|
||||
var classesQuery = db.AdministrativeClasses
|
||||
.Where(x => classIds.Contains(x.Id) && x.IsEnabled)
|
||||
.Where(x => x.IsEnabled)
|
||||
.WhereIn(classIds, x => x.Id)
|
||||
.Include(x => x.Major)
|
||||
.Include(x => x.Students)
|
||||
.AsQueryable();
|
||||
@@ -443,14 +444,15 @@ public sealed class TeachingTasksController(
|
||||
if (classes.Count != classIds.Length)
|
||||
return ValidationProblem("存在无效或不在当前数据范围内的行政班。");
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||
IsolationLevel.Serializable,
|
||||
cancellationToken);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var assignedClassIds = await db.TeachingTaskClasses.AsNoTracking()
|
||||
.Where(x =>
|
||||
classIds.Contains(x.AdministrativeClassId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.CourseId == request.CourseId)
|
||||
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||
.Select(x => x.AdministrativeClassId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -479,9 +481,9 @@ public sealed class TeachingTasksController(
|
||||
var eligibleTeacherIds = eligibleTeachers.Select(x => x.Id).ToArray();
|
||||
var existingLoads = await db.TeachingTaskTeachers.AsNoTracking()
|
||||
.Where(x =>
|
||||
eligibleTeacherIds.Contains(x.TeacherId) &&
|
||||
x.TeachingTask!.AcademicTermId == request.AcademicTermId &&
|
||||
x.TeachingTask.Status != TeachingTaskStatus.Closed)
|
||||
.WhereIn(eligibleTeacherIds, x => x.TeacherId)
|
||||
.GroupBy(x => x.TeacherId)
|
||||
.Select(group => new { TeacherId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(x => x.TeacherId, x => x.Count, cancellationToken);
|
||||
@@ -560,6 +562,9 @@ public sealed class TeachingTasksController(
|
||||
task.Capacity
|
||||
})
|
||||
});
|
||||
},
|
||||
cancellationToken,
|
||||
IsolationLevel.Serializable);
|
||||
}
|
||||
|
||||
private IQueryable<TeachingTask> ScopedTasks()
|
||||
@@ -599,25 +604,26 @@ public sealed class TeachingTasksController(
|
||||
if (request.PrimaryTeacherId.HasValue &&
|
||||
!teacherIds.Contains(request.PrimaryTeacherId.Value))
|
||||
return ValidationProblem("主讲教师必须包含在授课教师中。");
|
||||
if (await db.Teachers.CountAsync(
|
||||
x => teacherIds.Contains(x.Id) && x.Status == TeacherStatus.Active,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
if (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereIn(teacherIds, x => x.Id)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("存在无效或非在职授课教师。");
|
||||
if (teacherIds.Length > 0 &&
|
||||
await db.TeacherCourseApplications.CountAsync(
|
||||
x =>
|
||||
await db.TeacherCourseApplications
|
||||
.Where(x =>
|
||||
x.AcademicTermId == request.AcademicTermId &&
|
||||
x.CourseId == request.CourseId &&
|
||||
teacherIds.Contains(x.TeacherId) &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved,
|
||||
cancellationToken) != teacherIds.Length)
|
||||
x.Status == TeacherCourseApplicationStatus.Approved)
|
||||
.WhereIn(teacherIds, x => x.TeacherId)
|
||||
.CountAsync(cancellationToken) != teacherIds.Length)
|
||||
return ValidationProblem("授课教师必须已完成该课程申报并经学院审核通过。");
|
||||
|
||||
var classIds = request.ClassIds.Distinct().ToArray();
|
||||
if (classIds.Length > 0)
|
||||
{
|
||||
var classes = db.AdministrativeClasses.AsNoTracking()
|
||||
.Where(x => classIds.Contains(x.Id));
|
||||
.WhereIn(classIds, x => x.Id);
|
||||
if (collegeId.HasValue)
|
||||
classes = classes.Where(x => x.Major!.CollegeId == collegeId.Value);
|
||||
if (await classes.CountAsync(cancellationToken) != classIds.Length)
|
||||
@@ -673,11 +679,10 @@ public sealed class TeachingTasksController(
|
||||
.ToArray();
|
||||
var approvedApplications = await db.TeacherCourseApplications
|
||||
.AsNoTracking()
|
||||
.Where(x =>
|
||||
termIds.Contains(x.AcademicTermId) &&
|
||||
courseIds.Contains(x.CourseId) &&
|
||||
teacherIds.Contains(x.TeacherId) &&
|
||||
x.Status == TeacherCourseApplicationStatus.Approved)
|
||||
.Where(x => x.Status == TeacherCourseApplicationStatus.Approved)
|
||||
.WhereIn(termIds, x => x.AcademicTermId)
|
||||
.WhereIn(courseIds, x => x.CourseId)
|
||||
.WhereIn(teacherIds, x => x.TeacherId)
|
||||
.Select(x => new { x.AcademicTermId, x.CourseId, x.TeacherId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var approvedAssignments = approvedApplications
|
||||
|
||||
@@ -220,6 +220,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
var terms = await db.AcademicTerms.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -228,6 +229,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published)
|
||||
@@ -235,6 +237,7 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
.ToListAsync(cancellationToken);
|
||||
var selectedTermId = academicTermId
|
||||
?? terms.FirstOrDefault(x => x.IsCurrent && x.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault(x => !x.IsArchived && x.HasPublishedTimetable)?.Id
|
||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||
var campuses = await db.Campuses.AsNoTracking()
|
||||
.Where(x => x.IsEnabled)
|
||||
@@ -317,11 +320,12 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
.CountAsync(x => x.AcademicTermId == academicTermId, cancellationToken);
|
||||
var activePeriodCount = configuredPeriodCount == 0
|
||||
? requestedPeriods.Length
|
||||
: await db.ScheduleTimeSlots.AsNoTracking().CountAsync(x =>
|
||||
: await db.ScheduleTimeSlots.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.AcademicTermId == academicTermId &&
|
||||
x.IsEnabled &&
|
||||
requestedPeriods.Contains(x.PeriodNumber),
|
||||
cancellationToken);
|
||||
x.IsEnabled)
|
||||
.WhereIn(requestedPeriods, x => x.PeriodNumber)
|
||||
.CountAsync(cancellationToken);
|
||||
if (activePeriodCount != requestedPeriods.Length)
|
||||
return ValidationProblem("查询范围包含不存在或未启用的节次。");
|
||||
|
||||
@@ -357,8 +361,8 @@ public sealed class FreeClassroomsController(AppDbContext db) : ControllerBase
|
||||
.Where(x =>
|
||||
x.IsEnabled &&
|
||||
x.Building!.IsEnabled &&
|
||||
x.Building.Campus!.IsEnabled &&
|
||||
!occupiedIds.Contains(x.Id));
|
||||
x.Building.Campus!.IsEnabled)
|
||||
.WhereNotIn(occupiedIds, x => x.Id);
|
||||
if (campusId.HasValue)
|
||||
rooms = rooms.Where(x => x.Building!.CampusId == campusId.Value);
|
||||
if (buildingId.HasValue)
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed class TimetablesController(
|
||||
x.StartDate,
|
||||
x.EndDate,
|
||||
x.IsCurrent,
|
||||
x.IsArchived,
|
||||
HasPublishedTimetable = db.SchedulePlans.Any(plan =>
|
||||
plan.AcademicTermId == x.Id &&
|
||||
plan.Status == SchedulePlanStatus.Published) ||
|
||||
|
||||
@@ -25,8 +25,14 @@ public sealed class UsersController(
|
||||
.OrderBy(x => x.UserName)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id, x.UserName, x.DisplayName, x.StaffNumber,
|
||||
x.CollegeId, x.IsEnabled, x.LastLoginAt, x.CreatedAt
|
||||
x.Id,
|
||||
x.UserName,
|
||||
x.DisplayName,
|
||||
x.StaffNumber,
|
||||
x.CollegeId,
|
||||
x.IsEnabled,
|
||||
x.LastLoginAt,
|
||||
x.CreatedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -36,8 +42,14 @@ public sealed class UsersController(
|
||||
var identityUser = await userManager.FindByIdAsync(user.Id.ToString());
|
||||
result.Add(new
|
||||
{
|
||||
user.Id, user.UserName, user.DisplayName, user.StaffNumber,
|
||||
user.CollegeId, user.IsEnabled, user.LastLoginAt, user.CreatedAt,
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.DisplayName,
|
||||
user.StaffNumber,
|
||||
user.CollegeId,
|
||||
user.IsEnabled,
|
||||
user.LastLoginAt,
|
||||
user.CreatedAt,
|
||||
Roles = identityUser is null
|
||||
? []
|
||||
: await userManager.GetRolesAsync(identityUser)
|
||||
@@ -54,12 +66,18 @@ public sealed class UsersController(
|
||||
.ToListAsync(cancellationToken));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Create(CreateUserRequest request)
|
||||
public async Task<ActionResult> Create(
|
||||
CreateUserRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var invalidRoles = ValidateRoles(roles);
|
||||
if (invalidRoles is not null) return invalidRoles;
|
||||
var staffNumber = Normalize(request.StaffNumber);
|
||||
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
|
||||
@@ -72,7 +90,6 @@ public sealed class UsersController(
|
||||
LockoutEnabled = true,
|
||||
IsEnabled = true
|
||||
};
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
return IdentityValidationProblem(result);
|
||||
@@ -81,11 +98,18 @@ public sealed class UsersController(
|
||||
if (!result.Succeeded)
|
||||
return IdentityValidationProblem(result);
|
||||
|
||||
if (profiles.Teacher is not null) profiles.Teacher.UserId = user.Id;
|
||||
if (profiles.Student is not null) profiles.Student.UserId = user.Id;
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
return CreatedAtAction(nameof(GetUsers), new { id = user.Id }, new { user.Id });
|
||||
if (profiles.Teacher is not null)
|
||||
profiles.Teacher.UserId = user.Id;
|
||||
if (profiles.Student is not null)
|
||||
profiles.Student.UserId = user.Id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return CreatedAtAction(
|
||||
nameof(GetUsers),
|
||||
new { id = user.Id },
|
||||
new { user.Id });
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/status")]
|
||||
@@ -103,51 +127,79 @@ public sealed class UsersController(
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/roles")]
|
||||
public async Task<IActionResult> SetRoles(Guid id, SetRolesRequest request)
|
||||
public async Task<IActionResult> SetRoles(
|
||||
Guid id,
|
||||
SetRolesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(id.ToString());
|
||||
if (user is null) return NotFound();
|
||||
|
||||
var roles = request.Roles.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var invalidRoles = ValidateRoles(roles);
|
||||
if (invalidRoles is not null) return invalidRoles;
|
||||
var staffNumber = Normalize(request.StaffNumber);
|
||||
var profiles = await ResolveProfilesAsync(staffNumber, roles, user.Id);
|
||||
return await db.ExecuteInRetriableTransactionAsync<IActionResult>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var user = await userManager.FindByIdAsync(id.ToString());
|
||||
if (user is null) return NotFound();
|
||||
var profiles = await ResolveProfilesAsync(
|
||||
staffNumber,
|
||||
roles,
|
||||
user.Id);
|
||||
if (profiles.Error is not null) return profiles.Error;
|
||||
|
||||
var existing = await userManager.GetRolesAsync(user);
|
||||
if (User.FindFirstValue(ClaimTypes.NameIdentifier) == id.ToString() &&
|
||||
existing.Contains(SystemRoles.SuperAdmin) &&
|
||||
!roles.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
|
||||
!roles.Contains(
|
||||
SystemRoles.SuperAdmin,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return ValidationProblem("不能移除当前账号的超级管理员角色。");
|
||||
return ValidationProblem(
|
||||
"不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
user.StaffNumber = staffNumber;
|
||||
user.CollegeId = request.CollegeId;
|
||||
var updateResult = await userManager.UpdateAsync(user);
|
||||
if (!updateResult.Succeeded) return IdentityValidationProblem(updateResult);
|
||||
if (!updateResult.Succeeded)
|
||||
return IdentityValidationProblem(updateResult);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existing.Except(roles, StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded) return IdentityValidationProblem(removeResult);
|
||||
if (!removeResult.Succeeded)
|
||||
return IdentityValidationProblem(removeResult);
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
roles.Except(existing, StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded) return IdentityValidationProblem(addResult);
|
||||
if (!addResult.Succeeded)
|
||||
return IdentityValidationProblem(addResult);
|
||||
|
||||
var linkedTeachers = await db.Teachers.Where(x => x.UserId == id).ToListAsync();
|
||||
var linkedStudents = await db.Students.Where(x => x.UserId == id).ToListAsync();
|
||||
if (!roles.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var teacher in linkedTeachers) teacher.UserId = null;
|
||||
if (!roles.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var student in linkedStudents) student.UserId = null;
|
||||
if (profiles.Teacher is not null) profiles.Teacher.UserId = id;
|
||||
if (profiles.Student is not null) profiles.Student.UserId = id;
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
var linkedTeachers = await db.Teachers
|
||||
.Where(x => x.UserId == id)
|
||||
.ToListAsync();
|
||||
var linkedStudents = await db.Students
|
||||
.Where(x => x.UserId == id)
|
||||
.ToListAsync();
|
||||
if (!roles.Contains(
|
||||
SystemRoles.Teacher,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var teacher in linkedTeachers)
|
||||
teacher.UserId = null;
|
||||
if (!roles.Contains(
|
||||
SystemRoles.Student,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var student in linkedStudents)
|
||||
student.UserId = null;
|
||||
if (profiles.Teacher is not null)
|
||||
profiles.Teacher.UserId = id;
|
||||
if (profiles.Student is not null)
|
||||
profiles.Student.UserId = id;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return NoContent();
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private ActionResult? ValidateRoles(IReadOnlyCollection<string> roles)
|
||||
|
||||
@@ -83,14 +83,23 @@ public sealed class UsersExcelController(
|
||||
}
|
||||
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的账号。");
|
||||
|
||||
return await db.ExecuteInRetriableTransactionAsync<
|
||||
ActionResult<ExcelImportResult>>(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var colleges = await db.Colleges.AsNoTracking().ToDictionaryAsync(
|
||||
x => x.Code, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var users = await userManager.Users.ToDictionaryAsync(
|
||||
x => x.UserName!, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
var teachers = await db.Teachers.ToDictionaryAsync(
|
||||
x => x.TeacherNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
x => x.TeacherNumber,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
var students = await db.Students.ToDictionaryAsync(
|
||||
x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, cancellationToken);
|
||||
x => x.StudentNumber,
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
cancellationToken);
|
||||
var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var errors = new List<string>();
|
||||
var plans = new List<UserImportPlan>();
|
||||
@@ -124,37 +133,53 @@ public sealed class UsersExcelController(
|
||||
if (collegeCode is not null)
|
||||
{
|
||||
if (!colleges.TryGetValue(collegeCode, out var college))
|
||||
errors.Add($"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学院编码“{collegeCode}”不存在。");
|
||||
else collegeId = college.Id;
|
||||
}
|
||||
|
||||
Teacher? teacher = null;
|
||||
Student? student = null;
|
||||
if (roleNames.Contains(SystemRoles.Teacher, StringComparer.OrdinalIgnoreCase))
|
||||
if (roleNames.Contains(
|
||||
SystemRoles.Teacher,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null || !teachers.TryGetValue(staffNumber, out teacher))
|
||||
if (staffNumber is null ||
|
||||
!teachers.TryGetValue(staffNumber, out teacher))
|
||||
errors.Add($"第 {row.RowNumber} 行:教师角色必须填写有效工号。");
|
||||
else if (teacher.UserId.HasValue && teacher.UserId != existing?.Id)
|
||||
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
|
||||
else if (teacher.UserId.HasValue &&
|
||||
teacher.UserId != existing?.Id)
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:工号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedTeachers.Add(staffNumber))
|
||||
errors.Add($"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:工号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
if (roleNames.Contains(SystemRoles.Student, StringComparer.OrdinalIgnoreCase))
|
||||
if (roleNames.Contains(
|
||||
SystemRoles.Student,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (staffNumber is null || !students.TryGetValue(staffNumber, out student))
|
||||
if (staffNumber is null ||
|
||||
!students.TryGetValue(staffNumber, out student))
|
||||
errors.Add($"第 {row.RowNumber} 行:学生角色必须填写有效学号。");
|
||||
else if (student.UserId.HasValue && student.UserId != existing?.Id)
|
||||
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
|
||||
else if (student.UserId.HasValue &&
|
||||
student.UserId != existing?.Id)
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学号“{staffNumber}”已关联其他账号。");
|
||||
else if (!plannedStudents.Add(staffNumber))
|
||||
errors.Add($"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:学号“{staffNumber}”在文件中关联了多个账号。");
|
||||
}
|
||||
|
||||
if (existing?.Id.ToString() == currentUserId)
|
||||
{
|
||||
if (!enabled)
|
||||
errors.Add($"第 {row.RowNumber} 行:不能停用当前登录账号。");
|
||||
if (!roleNames.Contains(SystemRoles.SuperAdmin, StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add($"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
|
||||
if (!roleNames.Contains(
|
||||
SystemRoles.SuperAdmin,
|
||||
StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(
|
||||
$"第 {row.RowNumber} 行:不能移除当前账号的超级管理员角色。");
|
||||
}
|
||||
|
||||
plans.Add(new UserImportPlan(
|
||||
@@ -166,7 +191,6 @@ public sealed class UsersExcelController(
|
||||
|
||||
var created = 0;
|
||||
var updated = 0;
|
||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var user = plan.Existing;
|
||||
@@ -181,11 +205,15 @@ public sealed class UsersExcelController(
|
||||
IsEnabled = plan.IsEnabled,
|
||||
LockoutEnabled = true
|
||||
};
|
||||
var createResult = await userManager.CreateAsync(user, plan.Password!);
|
||||
var createResult = await userManager.CreateAsync(
|
||||
user,
|
||||
plan.Password!);
|
||||
if (!createResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(createResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(
|
||||
createResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
created++;
|
||||
}
|
||||
@@ -199,17 +227,24 @@ public sealed class UsersExcelController(
|
||||
if (!updateResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(updateResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(
|
||||
updateResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
if (plan.Password is not null)
|
||||
{
|
||||
var token = await userManager.GeneratePasswordResetTokenAsync(user);
|
||||
var token = await userManager
|
||||
.GeneratePasswordResetTokenAsync(user);
|
||||
var passwordResult = await userManager.ResetPasswordAsync(
|
||||
user, token, plan.Password);
|
||||
user,
|
||||
token,
|
||||
plan.Password);
|
||||
if (!passwordResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(passwordResult, plan.RowNumber);
|
||||
return IdentityValidationProblem(
|
||||
passwordResult,
|
||||
plan.RowNumber);
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
@@ -218,7 +253,9 @@ public sealed class UsersExcelController(
|
||||
var existingRoles = await userManager.GetRolesAsync(user);
|
||||
var removeResult = await userManager.RemoveFromRolesAsync(
|
||||
user,
|
||||
existingRoles.Except(plan.Roles, StringComparer.OrdinalIgnoreCase));
|
||||
existingRoles.Except(
|
||||
plan.Roles,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
if (!removeResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
@@ -226,17 +263,21 @@ public sealed class UsersExcelController(
|
||||
}
|
||||
var addResult = await userManager.AddToRolesAsync(
|
||||
user,
|
||||
plan.Roles.Except(existingRoles, StringComparer.OrdinalIgnoreCase));
|
||||
plan.Roles.Except(
|
||||
existingRoles,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
if (!addResult.Succeeded)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityValidationProblem(addResult, plan.RowNumber);
|
||||
}
|
||||
|
||||
foreach (var linked in await db.Teachers.Where(x => x.UserId == user.Id)
|
||||
foreach (var linked in await db.Teachers
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
if (linked != plan.Teacher) linked.UserId = null;
|
||||
foreach (var linked in await db.Students.Where(x => x.UserId == user.Id)
|
||||
foreach (var linked in await db.Students
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken))
|
||||
if (linked != plan.Student) linked.UserId = null;
|
||||
if (plan.Teacher is not null) plan.Teacher.UserId = user.Id;
|
||||
@@ -246,6 +287,8 @@ public sealed class UsersExcelController(
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return Ok(new ExcelImportResult(created, updated, plans.Count));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/password")]
|
||||
|
||||
@@ -34,9 +34,13 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
{
|
||||
db.WarningRules.Add(new WarningRule
|
||||
{
|
||||
AcademicTermId = academicTermId, Type = r.Type, Name = r.Name.Trim(),
|
||||
Threshold = r.Threshold, IsEnabled = r.IsEnabled,
|
||||
NotifyStudent = r.NotifyStudent, NotifyCounselor = r.NotifyCounselor,
|
||||
AcademicTermId = academicTermId,
|
||||
Type = r.Type,
|
||||
Name = r.Name.Trim(),
|
||||
Threshold = r.Threshold,
|
||||
IsEnabled = r.IsEnabled,
|
||||
NotifyStudent = r.NotifyStudent,
|
||||
NotifyCounselor = r.NotifyCounselor,
|
||||
Description = r.Description?.Trim(),
|
||||
AutoCheckEnabled = r.AutoCheckEnabled,
|
||||
CheckDayOfWeek = r.CheckDayOfWeek == 0 ? null : r.CheckDayOfWeek,
|
||||
@@ -110,7 +114,7 @@ public sealed class WarningsController(AppDbContext db, ICurrentUserDataScope sc
|
||||
{
|
||||
var classIds = await db.AdministrativeClasses.Where(c => c.CounselorUserId == scope.Current.UserId).Select(c => c.Id).ToListAsync(ct);
|
||||
if (classIds.Count > 0)
|
||||
q = q.Where(x => classIds.Contains(x.Student!.AdministrativeClassId));
|
||||
q = q.WhereIn(classIds, x => x.Student!.AdministrativeClassId);
|
||||
else if (scope.Current.RestrictedCollegeId.HasValue)
|
||||
q = q.Where(x => x.Student!.AdministrativeClass!.Major!.CollegeId == scope.Current.RestrictedCollegeId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ public sealed class AttendanceSheet : EntityBase
|
||||
public required string Name { get; set; }
|
||||
public DateTime AttendanceDate { get; set; }
|
||||
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
|
||||
public AttendanceCheckInMethod CheckInMethod { get; set; } =
|
||||
AttendanceCheckInMethod.Manual;
|
||||
public string? CheckInToken { get; set; }
|
||||
public DateTime? CheckInStartsAt { get; set; }
|
||||
public DateTime? CheckInEndsAt { get; set; }
|
||||
public decimal? TargetLatitude { get; set; }
|
||||
public decimal? TargetLongitude { get; set; }
|
||||
public int? LocationRadiusMeters { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||
@@ -22,6 +30,12 @@ public sealed class AttendanceRecord
|
||||
public Student? Student { get; set; }
|
||||
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? CheckInAt { get; set; }
|
||||
public AttendanceCheckInMethod? CheckedInMethod { get; set; }
|
||||
public decimal? CheckInLatitude { get; set; }
|
||||
public decimal? CheckInLongitude { get; set; }
|
||||
public double? CheckInAccuracyMeters { get; set; }
|
||||
public double? CheckInDistanceMeters { get; set; }
|
||||
public AttendanceAppealStatus AppealStatus { get; set; } = AttendanceAppealStatus.None;
|
||||
public string? AppealReason { get; set; }
|
||||
public DateTime? AppealSubmittedAt { get; set; }
|
||||
@@ -44,6 +58,13 @@ public enum AttendanceStatus
|
||||
Excused = 5
|
||||
}
|
||||
|
||||
public enum AttendanceCheckInMethod
|
||||
{
|
||||
Manual = 1,
|
||||
QrCode = 2,
|
||||
Location = 3
|
||||
}
|
||||
|
||||
public enum AttendanceAppealStatus
|
||||
{
|
||||
None = 0,
|
||||
|
||||
@@ -56,6 +56,8 @@ public sealed class AcademicTerm : CatalogEntity
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
public bool IsCurrent { get; set; }
|
||||
public bool IsArchived { get; set; }
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum TermSeason
|
||||
|
||||
@@ -168,7 +168,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
.ToHashSet();
|
||||
|
||||
if (occupiedRoomIds.Count > 0)
|
||||
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
||||
|
||||
// Exclude classrooms occupied by DB sessions not yet tracked in memory
|
||||
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
|
||||
@@ -181,7 +181,7 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (dbOccupiedRooms.Count > 0)
|
||||
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
|
||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
||||
|
||||
return await query
|
||||
.OrderBy(x => x.Capacity)
|
||||
@@ -213,8 +213,8 @@ public sealed class ExamArrangementService(AppDbContext db)
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyTeacherIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -164,7 +164,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.ToHashSet();
|
||||
|
||||
if (occupiedRoomIds.Count > 0)
|
||||
query = query.Where(x => !occupiedRoomIds.Contains(x.Id));
|
||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
||||
|
||||
var dbOccupiedRooms = await db.MakeupExamSessions.AsNoTracking()
|
||||
.Where(x => x.MakeupExamPlanId == session.MakeupExamPlanId &&
|
||||
@@ -176,7 +176,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (dbOccupiedRooms.Count > 0)
|
||||
query = query.Where(x => !dbOccupiedRooms.Contains(x.Id));
|
||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
||||
|
||||
return await query
|
||||
.OrderBy(x => x.Capacity)
|
||||
@@ -208,8 +208,8 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||
|
||||
return await db.Teachers.AsNoTracking()
|
||||
.Where(x => x.Status == TeacherStatus.Active &&
|
||||
!busyTeacherIds.Contains(x.Id))
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
||||
.OrderBy(x => Guid.NewGuid())
|
||||
.Take(needed)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -32,8 +32,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||
// 1. Approved deferred exams (highest priority)
|
||||
var deferredStudents = await db.DeferredExams.AsNoTracking()
|
||||
.Where(x => x.TeachingTaskId == teachingTaskId &&
|
||||
x.Status == ApprovalStatus.Approved &&
|
||||
!enrolledSet.Contains(x.StudentId))
|
||||
x.Status == ApprovalStatus.Approved)
|
||||
.WhereNotIn(enrolledSet, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -58,8 +58,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||
// 2. Absent students
|
||||
var absentStudents = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == gradeSheet.Id &&
|
||||
x.ExamStatus == GradeExamStatus.Absent &&
|
||||
!enrolledSet.Contains(x.StudentId))
|
||||
x.ExamStatus == GradeExamStatus.Absent)
|
||||
.WhereNotIn(enrolledSet, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
@@ -85,8 +85,8 @@ public sealed class MakeupExamEligibilityService(AppDbContext db)
|
||||
var failedStudents = await db.GradeRecords.AsNoTracking()
|
||||
.Where(x => x.GradeSheetId == gradeSheet.Id &&
|
||||
x.ExamStatus == GradeExamStatus.Normal &&
|
||||
x.TotalScore < 60 &&
|
||||
!enrolledSet.Contains(x.StudentId))
|
||||
x.TotalScore < 60)
|
||||
.WhereNotIn(enrolledSet, x => x.StudentId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.StudentId,
|
||||
|
||||
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Domain.System;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
@@ -90,6 +91,24 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
Set<GraduationClearanceRecord>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
protected override void ConfigureConventions(
|
||||
ModelConfigurationBuilder configurationBuilder)
|
||||
{
|
||||
base.ConfigureConventions(configurationBuilder);
|
||||
|
||||
// Connector/NET returns MySQL DATE values as DateTime. An explicit
|
||||
// provider conversion prevents EF from asking the reader for DateOnly.
|
||||
configurationBuilder.Properties<DateOnly>()
|
||||
.HaveConversion<DateOnlyDateTimeConverter>()
|
||||
.HaveColumnType("date");
|
||||
|
||||
// Connector/NET returns MySQL TIME values as TimeSpan. Convert at the
|
||||
// provider boundary so schedule time slots can still use TimeOnly.
|
||||
configurationBuilder.Properties<TimeOnly>()
|
||||
.HaveConversion<TimeOnlyTimeSpanConverter>()
|
||||
.HaveColumnType("time");
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
@@ -154,8 +173,11 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.HasForeignKey(x => x.BuildingId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<AcademicTerm>()
|
||||
.HasIndex(x => x.IsCurrent);
|
||||
builder.Entity<AcademicTerm>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.IsCurrent);
|
||||
entity.HasIndex(x => x.IsArchived);
|
||||
});
|
||||
|
||||
builder.Entity<Teacher>(entity =>
|
||||
{
|
||||
@@ -548,8 +570,12 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
builder.Entity<AttendanceSheet>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Name).HasMaxLength(120);
|
||||
entity.Property(x => x.CheckInToken).HasMaxLength(64);
|
||||
entity.Property(x => x.TargetLatitude).HasPrecision(10, 7);
|
||||
entity.Property(x => x.TargetLongitude).HasPrecision(10, 7);
|
||||
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
|
||||
entity.HasIndex(x => x.CheckInToken).IsUnique();
|
||||
entity.HasOne(x => x.TeachingTask)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
@@ -562,6 +588,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
entity.Property(x => x.Notes).HasMaxLength(300);
|
||||
entity.Property(x => x.AppealReason).HasMaxLength(500);
|
||||
entity.Property(x => x.AppealReviewComment).HasMaxLength(300);
|
||||
entity.Property(x => x.CheckInLatitude).HasPrecision(10, 7);
|
||||
entity.Property(x => x.CheckInLongitude).HasPrecision(10, 7);
|
||||
entity.HasIndex(x => x.AppealStatus);
|
||||
entity.HasOne(x => x.AttendanceSheet)
|
||||
.WithMany(x => x.Records)
|
||||
@@ -904,3 +932,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DateOnlyDateTimeConverter()
|
||||
: ValueConverter<DateOnly, DateTime>(
|
||||
date => date.ToDateTime(TimeOnly.MinValue),
|
||||
value => DateOnly.FromDateTime(value));
|
||||
|
||||
public sealed class TimeOnlyTimeSpanConverter()
|
||||
: ValueConverter<TimeOnly, TimeSpan>(
|
||||
time => time.ToTimeSpan(),
|
||||
value => TimeOnly.FromTimeSpan(value));
|
||||
|
||||
@@ -8,6 +8,8 @@ public sealed class DemoDataSeeder(
|
||||
AppDbContext db,
|
||||
ILogger<DemoDataSeeder> logger)
|
||||
{
|
||||
// MySql.EntityFrameworkCore 10 cannot type-map parameterized primitive
|
||||
// collections, so membership in definition/ID sets is checked after loading.
|
||||
private const int Grade = 2026;
|
||||
private const int ClassesPerMajor = 2;
|
||||
private const int StudentsPerClass = 35;
|
||||
@@ -176,14 +178,18 @@ public sealed class DemoDataSeeder(
|
||||
.SelectMany(x => x.Majors)
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var majors = await db.Majors
|
||||
var majors = (await db.Majors
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => targetMajorCodes.Contains(x.Code))
|
||||
.ToList();
|
||||
var majorIds = majors.Select(x => x.Id).ToHashSet();
|
||||
var existingClasses = (await db.AdministrativeClasses
|
||||
.Where(x => x.Grade == Grade)
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingClasses = await db.AdministrativeClasses
|
||||
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => majorIds.Contains(x.MajorId))
|
||||
.ToList();
|
||||
var existingCodes = (await db.AdministrativeClasses
|
||||
.Select(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
@@ -218,13 +224,16 @@ public sealed class DemoDataSeeder(
|
||||
var collegeCodes = CollegeDefinitions
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var colleges = await db.Colleges
|
||||
.Where(x => collegeCodes.Contains(x.Code))
|
||||
var colleges = (await db.Colleges
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingTeachers = await db.Teachers
|
||||
.Where(x => colleges.Select(c => c.Id).Contains(x.CollegeId))
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => collegeCodes.Contains(x.Code))
|
||||
.ToList();
|
||||
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
|
||||
var existingTeachers = (await db.Teachers
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => collegeIds.Contains(x.CollegeId))
|
||||
.ToList();
|
||||
var existingNumbers = (await db.Teachers
|
||||
.Select(x => x.TeacherNumber)
|
||||
.ToListAsync(cancellationToken))
|
||||
@@ -264,14 +273,23 @@ public sealed class DemoDataSeeder(
|
||||
.SelectMany(x => x.Majors)
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var classes = await db.AdministrativeClasses
|
||||
.Where(x => x.Grade == Grade && targetMajorCodes.Contains(x.Major!.Code))
|
||||
var targetMajorIds = (await db.Majors
|
||||
.Select(x => new { x.Id, x.Code })
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => targetMajorCodes.Contains(x.Code))
|
||||
.Select(x => x.Id)
|
||||
.ToHashSet();
|
||||
var classes = (await db.AdministrativeClasses
|
||||
.Where(x => x.Grade == Grade)
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => targetMajorIds.Contains(x.MajorId))
|
||||
.ToList();
|
||||
var classIds = classes.Select(x => x.Id).ToHashSet();
|
||||
var existingStudents = await db.Students
|
||||
var existingStudents = (await db.Students
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => classIds.Contains(x.AdministrativeClassId))
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
var existingNumbers = (await db.Students
|
||||
.Select(x => x.StudentNumber)
|
||||
.ToListAsync(cancellationToken))
|
||||
@@ -404,19 +422,23 @@ public sealed class DemoDataSeeder(
|
||||
var collegeCodes = CollegeDefinitions
|
||||
.Select(x => x.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var colleges = await db.Colleges
|
||||
var colleges = (await db.Colleges
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => collegeCodes.Contains(x.Code))
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
var collegeIds = colleges.Select(x => x.Id).ToHashSet();
|
||||
var teachers = await db.Teachers
|
||||
.Where(x => collegeIds.Contains(x.CollegeId) && x.Status == TeacherStatus.Active)
|
||||
var teachers = (await db.Teachers
|
||||
.Where(x => x.Status == TeacherStatus.Active)
|
||||
.OrderBy(x => x.TeacherNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
var courses = await db.Courses
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => collegeIds.Contains(x.CollegeId))
|
||||
.ToList();
|
||||
var courses = (await db.Courses
|
||||
.OrderBy(x => x.Code)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToListAsync(cancellationToken))
|
||||
.Where(x => collegeIds.Contains(x.CollegeId))
|
||||
.ToList();
|
||||
var publicCourses = courses
|
||||
.Where(x => x.Nature is CourseNature.GeneralRequired or CourseNature.GeneralElective)
|
||||
.ToList();
|
||||
|
||||
@@ -40,10 +40,14 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"20260725_22_course_adjustments";
|
||||
private const string AttendanceAppealMigration =
|
||||
"20260725_23_attendance_appeal";
|
||||
private const string AttendanceCheckInMigration =
|
||||
"20260726_26_attendance_check_in";
|
||||
private const string ApprovalTablesMigration =
|
||||
"20260725_24_approval_tables";
|
||||
private const string AcademicWarningsMigration =
|
||||
"20260725_25_academic_warnings";
|
||||
private const string AcademicTermArchivingMigration =
|
||||
"20260726_27_academic_term_archiving";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -275,6 +279,19 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
attendanceAppealExists ? [] : AttendanceAppealStatements,
|
||||
cancellationToken);
|
||||
|
||||
var attendanceCheckInExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AttendanceSheets')
|
||||
WHERE name = 'CheckInMethod'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AttendanceCheckInMigration,
|
||||
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
||||
cancellationToken);
|
||||
|
||||
var approvalTablesExist = await db.Database
|
||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
@@ -314,6 +331,19 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
MakeupExamAutoJobsMigration,
|
||||
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
||||
cancellationToken);
|
||||
|
||||
var academicTermArchivingExists = await db.Database
|
||||
.SqlQueryRaw<int>(
|
||||
"""
|
||||
SELECT COUNT(*) AS "Value"
|
||||
FROM pragma_table_info('AcademicTerms')
|
||||
WHERE name = 'IsArchived'
|
||||
""")
|
||||
.AnyAsync(value => value > 0, cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
AcademicTermArchivingMigration,
|
||||
academicTermArchivingExists ? [] : AcademicTermArchivingStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -1623,6 +1653,24 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AppealStatus" ON "AttendanceRecords" ("AppealStatus");"""
|
||||
];
|
||||
|
||||
private static readonly string[] AttendanceCheckInStatements =
|
||||
[
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInMethod" INTEGER NOT NULL DEFAULT 1;""",
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInToken" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInStartsAt" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "CheckInEndsAt" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLatitude" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "TargetLongitude" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceSheets" ADD COLUMN "LocationRadiusMeters" INTEGER NULL;""",
|
||||
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_AttendanceSheets_CheckInToken" ON "AttendanceSheets" ("CheckInToken");""",
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAt" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckedInMethod" INTEGER NULL;""",
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLatitude" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInLongitude" TEXT NULL;""",
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInAccuracyMeters" REAL NULL;""",
|
||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] ApprovalTableStatements =
|
||||
[
|
||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||
@@ -1655,6 +1703,13 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
"""ALTER TABLE "WarningRules" ADD COLUMN "LastCheckAt" TEXT NULL;"""
|
||||
];
|
||||
|
||||
private static readonly string[] AcademicTermArchivingStatements =
|
||||
[
|
||||
"""ALTER TABLE "AcademicTerms" ADD COLUMN "IsArchived" INTEGER NOT NULL DEFAULT 0;""",
|
||||
"""ALTER TABLE "AcademicTerms" ADD COLUMN "ArchivedAt" TEXT NULL;""",
|
||||
"""CREATE INDEX IF NOT EXISTS "IX_AcademicTerms_IsArchived" ON "AcademicTerms" ("IsArchived");"""
|
||||
];
|
||||
|
||||
private const string TeachingEvaluationMigration = "TeachingEvaluation";
|
||||
|
||||
private static readonly string[] TeachingEvaluationStatements =
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260726022634_AttendanceCheckIn")]
|
||||
public partial class AttendanceCheckIn : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CheckInMethod",
|
||||
table: "AttendanceSheets",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CheckInToken",
|
||||
table: "AttendanceSheets",
|
||||
type: "varchar(64)",
|
||||
maxLength: 64,
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CheckInStartsAt",
|
||||
table: "AttendanceSheets",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CheckInEndsAt",
|
||||
table: "AttendanceSheets",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TargetLatitude",
|
||||
table: "AttendanceSheets",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "TargetLongitude",
|
||||
table: "AttendanceSheets",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LocationRadiusMeters",
|
||||
table: "AttendanceSheets",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CheckInAt",
|
||||
table: "AttendanceRecords",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CheckedInMethod",
|
||||
table: "AttendanceRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "CheckInLatitude",
|
||||
table: "AttendanceRecords",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "CheckInLongitude",
|
||||
table: "AttendanceRecords",
|
||||
type: "decimal(10,7)",
|
||||
precision: 10,
|
||||
scale: 7,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "CheckInAccuracyMeters",
|
||||
table: "AttendanceRecords",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "CheckInDistanceMeters",
|
||||
table: "AttendanceRecords",
|
||||
type: "double",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AttendanceSheets_CheckInToken",
|
||||
table: "AttendanceSheets",
|
||||
column: "CheckInToken",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AttendanceSheets_CheckInToken",
|
||||
table: "AttendanceSheets");
|
||||
|
||||
migrationBuilder.DropColumn(name: "CheckInMethod", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "CheckInToken", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "CheckInStartsAt", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "CheckInEndsAt", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "TargetLatitude", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "TargetLongitude", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "LocationRadiusMeters", table: "AttendanceSheets");
|
||||
migrationBuilder.DropColumn(name: "CheckInAt", table: "AttendanceRecords");
|
||||
migrationBuilder.DropColumn(name: "CheckedInMethod", table: "AttendanceRecords");
|
||||
migrationBuilder.DropColumn(name: "CheckInLatitude", table: "AttendanceRecords");
|
||||
migrationBuilder.DropColumn(name: "CheckInLongitude", table: "AttendanceRecords");
|
||||
migrationBuilder.DropColumn(name: "CheckInAccuracyMeters", table: "AttendanceRecords");
|
||||
migrationBuilder.DropColumn(name: "CheckInDistanceMeters", table: "AttendanceRecords");
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql;
|
||||
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260726143000_AcademicTermArchiving")]
|
||||
public partial class AcademicTermArchiving : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ArchivedAt",
|
||||
table: "AcademicTerms",
|
||||
type: "datetime(6)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsArchived",
|
||||
table: "AcademicTerms",
|
||||
type: "tinyint(1)",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AcademicTerms_IsArchived",
|
||||
table: "AcademicTerms",
|
||||
column: "IsArchived");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_AcademicTerms_IsArchived",
|
||||
table: "AcademicTerms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ArchivedAt",
|
||||
table: "AcademicTerms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsArchived",
|
||||
table: "AcademicTerms");
|
||||
}
|
||||
}
|
||||
+55
@@ -29,6 +29,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<DateTime?>("ArchivedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
@@ -43,6 +46,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<bool>("IsCurrent")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsArchived")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
@@ -70,6 +76,8 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasIndex("IsCurrent");
|
||||
|
||||
b.HasIndex("IsArchived");
|
||||
|
||||
b.HasIndex("IsEnabled", "SortOrder");
|
||||
|
||||
b.ToTable("AcademicTerms");
|
||||
@@ -154,6 +162,26 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime?>("AppealSubmittedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<double?>("CheckInAccuracyMeters")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<DateTime?>("CheckInAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<double?>("CheckInDistanceMeters")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<decimal?>("CheckInLatitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("CheckInLongitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<int?>("CheckedInMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
@@ -179,9 +207,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime>("AttendanceDate")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime?>("CheckInEndsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("CheckInMethod")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("CheckInStartsAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<string>("CheckInToken")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("varchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int?>("LocationRadiusMeters")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
@@ -197,6 +241,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Property<DateTime?>("SubmittedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal?>("TargetLatitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("TargetLongitude")
|
||||
.HasPrecision(10, 7)
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<Guid>("TeachingTaskId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
@@ -205,6 +257,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheckInToken")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TeachingTaskId", "AttendanceDate");
|
||||
|
||||
b.ToTable("AttendanceSheets");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class QueryableCollectionExtensions
|
||||
{
|
||||
public static IQueryable<TEntity> WhereIn<TEntity, TValue>(
|
||||
this IQueryable<TEntity> source,
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector)
|
||||
{
|
||||
var predicate = BuildPredicate(values, valueSelector, Expression.OrElse, false);
|
||||
return source.Where(predicate);
|
||||
}
|
||||
|
||||
public static IQueryable<TEntity> WhereNotIn<TEntity, TValue>(
|
||||
this IQueryable<TEntity> source,
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector)
|
||||
{
|
||||
var predicate = BuildPredicate(values, valueSelector, Expression.AndAlso, true);
|
||||
return source.Where(predicate);
|
||||
}
|
||||
|
||||
private static Expression<Func<TEntity, bool>> BuildPredicate<TEntity, TValue>(
|
||||
IEnumerable<TValue> values,
|
||||
Expression<Func<TEntity, TValue>> valueSelector,
|
||||
Func<Expression, Expression, BinaryExpression> combine,
|
||||
bool negate)
|
||||
{
|
||||
Expression? body = null;
|
||||
foreach (var value in values.Distinct())
|
||||
{
|
||||
Expression comparison = Expression.Equal(
|
||||
valueSelector.Body,
|
||||
Expression.Constant(value, typeof(TValue)));
|
||||
if (negate)
|
||||
comparison = Expression.Not(comparison);
|
||||
body = body is null ? comparison : combine(body, comparison);
|
||||
}
|
||||
|
||||
body ??= Expression.Constant(negate);
|
||||
return Expression.Lambda<Func<TEntity, bool>>(
|
||||
body,
|
||||
valueSelector.Parameters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence;
|
||||
|
||||
public static class RetriableTransactionExtensions
|
||||
{
|
||||
public static Task<TResult> ExecuteInRetriableTransactionAsync<TResult>(
|
||||
this DbContext db,
|
||||
Func<IDbContextTransaction, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken,
|
||||
IsolationLevel? isolationLevel = null)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = isolationLevel.HasValue
|
||||
? await db.Database.BeginTransactionAsync(
|
||||
isolationLevel.Value,
|
||||
cancellationToken)
|
||||
: await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
return await operation(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
public static Task ExecuteInRetriableTransactionAsync(
|
||||
this DbContext db,
|
||||
Func<IDbContextTransaction, Task> operation,
|
||||
CancellationToken cancellationToken,
|
||||
IsolationLevel? isolationLevel = null)
|
||||
{
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = isolationLevel.HasValue
|
||||
? await db.Database.BeginTransactionAsync(
|
||||
isolationLevel.Value,
|
||||
cancellationToken)
|
||||
: await db.Database.BeginTransactionAsync(cancellationToken);
|
||||
await operation(transaction);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,9 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
||||
.ThenByDescending(x => x.Capacity)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
var taskIds = tasks.Select(task => task.Id).ToArray();
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Where(x => tasks.Select(task => task.Id).Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
var classrooms = await db.Classrooms.AsNoTracking()
|
||||
|
||||
@@ -169,10 +169,7 @@ public sealed class AutomaticScheduleJobProcessor(
|
||||
job.CompletedTasks = result.CompletedTasks;
|
||||
job.MessagesJson = JsonSerializer.Serialize(result.Messages);
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Automatic schedule job {JobId} completed with {CreatedEntries} entries.",
|
||||
|
||||
@@ -141,35 +141,46 @@ public sealed class SchedulePublishJobProcessor(
|
||||
ReportProgress,
|
||||
stoppingToken);
|
||||
|
||||
await using var transaction =
|
||||
await db.Database.BeginTransactionAsync(stoppingToken);
|
||||
if (plan.Status != SchedulePlanStatus.Draft)
|
||||
var publishedPlanId = plan.Id;
|
||||
await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
var publishJob = await db.SchedulePublishJobs
|
||||
.FirstAsync(x => x.Id == jobId, stoppingToken);
|
||||
var publishPlan = await db.SchedulePlans
|
||||
.FirstAsync(x => x.Id == publishedPlanId, stoppingToken);
|
||||
if (publishPlan.Status != SchedulePlanStatus.Draft)
|
||||
{
|
||||
throw new SchedulePublishValidationException(
|
||||
"排课草稿状态已发生变化,请刷新后重试。");
|
||||
}
|
||||
|
||||
var previous = await db.SchedulePlans
|
||||
.Where(x =>
|
||||
x.Id != plan.Id &&
|
||||
x.AcademicTermId == plan.AcademicTermId &&
|
||||
x.Id != publishPlan.Id &&
|
||||
x.AcademicTermId == publishPlan.AcademicTermId &&
|
||||
x.Status == SchedulePlanStatus.Published)
|
||||
.ToListAsync(stoppingToken);
|
||||
foreach (var oldPlan in previous)
|
||||
oldPlan.Status = SchedulePlanStatus.Archived;
|
||||
|
||||
plan.Status = SchedulePlanStatus.Published;
|
||||
plan.PublishedAt = DateTime.UtcNow;
|
||||
job.Status = SchedulePublishJobStatus.Succeeded;
|
||||
job.ActiveAcademicTermId = null;
|
||||
job.CompletedSteps = job.TotalSteps;
|
||||
job.CurrentStep = "课表已发布";
|
||||
job.CompletedAt = DateTime.UtcNow;
|
||||
publishPlan.Status = SchedulePlanStatus.Published;
|
||||
publishPlan.PublishedAt = DateTime.UtcNow;
|
||||
publishJob.Status = SchedulePublishJobStatus.Succeeded;
|
||||
publishJob.ActiveAcademicTermId = null;
|
||||
publishJob.CompletedSteps = publishJob.TotalSteps;
|
||||
publishJob.CurrentStep = "课表已发布";
|
||||
publishJob.CompletedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
await transaction.CommitAsync(stoppingToken);
|
||||
},
|
||||
stoppingToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Schedule publish job {JobId} published plan {SchedulePlanId}.",
|
||||
job.Id,
|
||||
plan.Id);
|
||||
jobId,
|
||||
publishedPlanId);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -245,7 +256,7 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
||||
|
||||
var taskIds = plan.Entries.Select(x => x.TeachingTaskId).Distinct().ToList();
|
||||
var constraints = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||
.Where(x => taskIds.Contains(x.TeachingTaskId))
|
||||
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||
.Include(x => x.AllowedClassrooms)
|
||||
.ToDictionaryAsync(x => x.TeachingTaskId, cancellationToken);
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
||||
query = query.Where(x => x.Id == academicTermId);
|
||||
else
|
||||
query = query.OrderByDescending(x => x.IsCurrent)
|
||||
.ThenBy(x => x.IsArchived)
|
||||
.ThenByDescending(x => x.StartDate);
|
||||
return await query.Select(x => new TimetableTermDto(
|
||||
x.Id,
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,19 @@ public sealed class AttendanceControllerTests
|
||||
MajorId = major.Id,
|
||||
Grade = 2026
|
||||
};
|
||||
var studentUserId = Guid.NewGuid();
|
||||
var studentUser = new ApplicationUser
|
||||
{
|
||||
Id = studentUserId,
|
||||
UserName = "202601001",
|
||||
NormalizedUserName = "202601001",
|
||||
DisplayName = "周同学"
|
||||
};
|
||||
var firstStudent = new Student
|
||||
{
|
||||
StudentNumber = "202601001",
|
||||
Name = "周同学",
|
||||
UserId = studentUserId,
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||
@@ -85,6 +94,7 @@ public sealed class AttendanceControllerTests
|
||||
Status = TeachingTaskStatus.Published
|
||||
};
|
||||
db.AddRange(
|
||||
studentUser,
|
||||
college,
|
||||
major,
|
||||
administrativeClass,
|
||||
@@ -181,6 +191,111 @@ public sealed class AttendanceControllerTests
|
||||
Assert.Equal(
|
||||
3,
|
||||
workbook.Worksheet("历次点名趋势").LastRowUsed()!.RowNumber());
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var qrRecord = new AttendanceRecord
|
||||
{
|
||||
StudentId = firstStudent.Id,
|
||||
Status = AttendanceStatus.Absent
|
||||
};
|
||||
var qrSheet = new AttendanceSheet
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
Name = "课堂扫码签到",
|
||||
AttendanceDate = now,
|
||||
Status = AttendanceSheetStatus.Draft,
|
||||
CheckInMethod = AttendanceCheckInMethod.QrCode,
|
||||
CheckInToken = "TEST-QR-TOKEN",
|
||||
CheckInStartsAt = now.AddMinutes(-1),
|
||||
CheckInEndsAt = now.AddMinutes(10),
|
||||
Records = [qrRecord]
|
||||
};
|
||||
db.AttendanceSheets.Add(qrSheet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var studentController = new AttendanceController(
|
||||
db,
|
||||
new StudentDataScope(studentUserId));
|
||||
var myRecordsResult = await studentController.GetMyRecords(
|
||||
null,
|
||||
CancellationToken.None);
|
||||
var myRecordsOk = Assert.IsType<OkObjectResult>(myRecordsResult);
|
||||
var myRecords = Assert
|
||||
.IsAssignableFrom<System.Collections.IEnumerable>(myRecordsOk.Value)
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
Assert.Equal(2, myRecords.Count);
|
||||
Assert.All(myRecords, item =>
|
||||
Assert.Equal(
|
||||
task.Id,
|
||||
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
|
||||
|
||||
var infoResult = await studentController.GetCheckInInfo(
|
||||
qrSheet.CheckInToken,
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(infoResult);
|
||||
|
||||
var qrCheckInResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
null,
|
||||
qrSheet.CheckInToken,
|
||||
null,
|
||||
null,
|
||||
null),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(qrCheckInResult);
|
||||
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
|
||||
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
|
||||
Assert.NotNull(qrRecord.CheckInAt);
|
||||
Assert.Null(qrRecord.CheckInLatitude);
|
||||
|
||||
var locationRecord = new AttendanceRecord
|
||||
{
|
||||
StudentId = firstStudent.Id,
|
||||
Status = AttendanceStatus.Absent
|
||||
};
|
||||
var locationSheet = new AttendanceSheet
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
Name = "课堂定位签到",
|
||||
AttendanceDate = now,
|
||||
Status = AttendanceSheetStatus.Draft,
|
||||
CheckInMethod = AttendanceCheckInMethod.Location,
|
||||
CheckInStartsAt = now.AddMinutes(-1),
|
||||
CheckInEndsAt = now.AddMinutes(10),
|
||||
TargetLatitude = 39.9m,
|
||||
TargetLongitude = 116.4m,
|
||||
LocationRadiusMeters = 100,
|
||||
Records = [locationRecord]
|
||||
};
|
||||
db.AttendanceSheets.Add(locationSheet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var outsideResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
locationSheet.Id,
|
||||
null,
|
||||
39.91m,
|
||||
116.4m,
|
||||
8),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<ConflictObjectResult>(outsideResult);
|
||||
Assert.Null(locationRecord.CheckInAt);
|
||||
|
||||
var nearbyResult = await studentController.CheckIn(
|
||||
new AttendanceCheckInRequest(
|
||||
locationSheet.Id,
|
||||
null,
|
||||
39.9001m,
|
||||
116.4m,
|
||||
8),
|
||||
CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(nearbyResult);
|
||||
Assert.Equal(AttendanceStatus.Present, locationRecord.Status);
|
||||
Assert.Equal(
|
||||
AttendanceCheckInMethod.Location,
|
||||
locationRecord.CheckedInMethod);
|
||||
Assert.InRange(locationRecord.CheckInDistanceMeters!.Value, 1, 100);
|
||||
}
|
||||
|
||||
private sealed class AllDataScope : ICurrentUserDataScope
|
||||
@@ -192,4 +307,14 @@ public sealed class AttendanceControllerTests
|
||||
DataScope.All,
|
||||
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||
}
|
||||
|
||||
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||
{
|
||||
public CurrentUserScope Current { get; } = new(
|
||||
userId,
|
||||
"测试学生",
|
||||
null,
|
||||
DataScope.Self,
|
||||
new HashSet<string>([SystemRoles.Student]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class AuthControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetriableTransaction_WorksWithRetryingExecutionStrategy()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>()
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||
await db.ExecuteInRetriableTransactionAsync(
|
||||
async transaction =>
|
||||
{
|
||||
db.Colleges.Add(new College { Code = "TX", Name = "事务测试学院" });
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, await db.Colleges.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateStudent_WorksWithRetryingExecutionStrategy()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContext<AppDbContext>(options => options
|
||||
.UseSqlite(connection)
|
||||
.ReplaceService<IExecutionStrategyFactory, RetryingExecutionStrategyFactory>());
|
||||
services
|
||||
.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.Password.RequiredLength = 8;
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = true;
|
||||
})
|
||||
.AddRoles<ApplicationRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>();
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
await using var scope = provider.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var roleManager = scope.ServiceProvider
|
||||
.GetRequiredService<RoleManager<ApplicationRole>>();
|
||||
Assert.True((await roleManager.CreateAsync(new ApplicationRole
|
||||
{
|
||||
Name = SystemRoles.Student,
|
||||
Description = "学生"
|
||||
})).Succeeded);
|
||||
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var major = new Major
|
||||
{
|
||||
Code = "SE",
|
||||
Name = "软件工程",
|
||||
CollegeId = college.Id,
|
||||
DegreeType = "工学"
|
||||
};
|
||||
var administrativeClass = new AdministrativeClass
|
||||
{
|
||||
Code = "SE202601",
|
||||
Name = "软件工程 2026 级 1 班",
|
||||
MajorId = major.Id,
|
||||
Grade = 2026
|
||||
};
|
||||
var student = new Student
|
||||
{
|
||||
StudentNumber = "202601999",
|
||||
Name = "测试学生",
|
||||
AdministrativeClassId = administrativeClass.Id,
|
||||
EnrollmentYear = 2026,
|
||||
EnrollmentDate = new DateOnly(2026, 9, 1),
|
||||
Status = StudentStatus.Active
|
||||
};
|
||||
db.AddRange(college, major, administrativeClass, student);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var userManager = scope.ServiceProvider
|
||||
.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
Assert.True(db.Database.CreateExecutionStrategy().RetriesOnFailure);
|
||||
var controller = new AuthController(db, userManager, new StubTokenService());
|
||||
var request = new StudentActivationRequest(
|
||||
student.Name,
|
||||
student.StudentNumber,
|
||||
college.Id,
|
||||
major.Id,
|
||||
administrativeClass.Grade,
|
||||
administrativeClass.Id,
|
||||
"Student@123");
|
||||
|
||||
var result = await controller.ActivateStudent(request, CancellationToken.None);
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
var user = await userManager.FindByNameAsync(student.StudentNumber);
|
||||
Assert.NotNull(user);
|
||||
Assert.Equal(student.Name, user.DisplayName);
|
||||
Assert.Equal(college.Id, user.CollegeId);
|
||||
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Student));
|
||||
await db.Entry(student).ReloadAsync();
|
||||
Assert.Equal(user.Id, student.UserId);
|
||||
}
|
||||
|
||||
private sealed class RetryingExecutionStrategyFactory(
|
||||
ExecutionStrategyDependencies dependencies) : IExecutionStrategyFactory
|
||||
{
|
||||
public IExecutionStrategy Create() => new TestRetryingExecutionStrategy(dependencies);
|
||||
}
|
||||
|
||||
private sealed class TestRetryingExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies)
|
||||
: ExecutionStrategy(dependencies, 1, TimeSpan.Zero)
|
||||
{
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
private sealed class StubTokenService : ITokenService
|
||||
{
|
||||
public string Create(ApplicationUser user, IEnumerable<string> roles) => string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Jiaowu.Api.Controllers;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class BaseDataControllerTests : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection connection = new("Data Source=:memory:");
|
||||
private readonly AppDbContext db;
|
||||
private readonly BaseDataController controller;
|
||||
|
||||
public BaseDataControllerTests()
|
||||
{
|
||||
connection.Open();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
db = new AppDbContext(options);
|
||||
db.Database.EnsureCreated();
|
||||
controller = new BaseDataController(db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetCurrentTerm_clears_previous_current_in_one_save()
|
||||
{
|
||||
var previous = CreateTerm("2025-2026-2", true);
|
||||
var next = CreateTerm("2026-2027-1", false);
|
||||
db.AcademicTerms.AddRange(previous, next);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await controller.SetCurrentTerm(next.Id, CancellationToken.None);
|
||||
|
||||
Assert.Null(result.Result);
|
||||
db.ChangeTracker.Clear();
|
||||
Assert.False((await db.AcademicTerms.FindAsync(previous.Id))!.IsCurrent);
|
||||
Assert.True((await db.AcademicTerms.FindAsync(next.Id))!.IsCurrent);
|
||||
Assert.Equal(1, await db.AcademicTerms.CountAsync(x => x.IsCurrent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Archive_requires_switching_current_and_is_reversible()
|
||||
{
|
||||
var current = CreateTerm("2026-2027-1", true);
|
||||
var history = CreateTerm("2025-2026-2", false);
|
||||
db.AcademicTerms.AddRange(current, history);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var currentResult = await controller.ArchiveTerm(
|
||||
current.Id, CancellationToken.None);
|
||||
Assert.IsType<ConflictObjectResult>(currentResult.Result);
|
||||
|
||||
var archiveResult = await controller.ArchiveTerm(
|
||||
history.Id, CancellationToken.None);
|
||||
Assert.Null(archiveResult.Result);
|
||||
Assert.True(history.IsArchived);
|
||||
Assert.NotNull(history.ArchivedAt);
|
||||
Assert.True(history.IsEnabled);
|
||||
|
||||
var unarchiveResult = await controller.UnarchiveTerm(
|
||||
history.Id, CancellationToken.None);
|
||||
Assert.Null(unarchiveResult.Result);
|
||||
Assert.False(history.IsArchived);
|
||||
Assert.Null(history.ArchivedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Archived_term_must_be_unarchived_before_becoming_current()
|
||||
{
|
||||
var current = CreateTerm("2026-2027-1", true);
|
||||
var archived = CreateTerm("2025-2026-2", false);
|
||||
archived.IsArchived = true;
|
||||
archived.ArchivedAt = DateTime.UtcNow;
|
||||
db.AcademicTerms.AddRange(current, archived);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await controller.SetCurrentTerm(
|
||||
archived.Id, CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
Assert.True(current.IsCurrent);
|
||||
Assert.False(archived.IsCurrent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Current_term_cannot_be_cleared_without_a_replacement()
|
||||
{
|
||||
var current = CreateTerm("2026-2027-1", true);
|
||||
db.AcademicTerms.Add(current);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var request = new TermRequest(
|
||||
current.Code,
|
||||
current.Name,
|
||||
true,
|
||||
current.AcademicYear,
|
||||
current.Season,
|
||||
current.StartDate,
|
||||
current.EndDate,
|
||||
false);
|
||||
var result = await controller.UpdateTerm(
|
||||
current.Id, request, CancellationToken.None);
|
||||
|
||||
Assert.IsType<ConflictObjectResult>(result.Result);
|
||||
Assert.True(current.IsCurrent);
|
||||
}
|
||||
|
||||
private static AcademicTerm CreateTerm(string code, bool isCurrent) =>
|
||||
new()
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
AcademicYear = code[..9],
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 1),
|
||||
EndDate = new DateOnly(2027, 1, 20),
|
||||
IsCurrent = isCurrent,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await db.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,12 @@ namespace Jiaowu.Api.Tests;
|
||||
public sealed class MySqlMigrationTests
|
||||
{
|
||||
private const string LatestMigration =
|
||||
"20260725120917_ProductionSchemaCompletion";
|
||||
"20260726022634_AttendanceCheckIn";
|
||||
|
||||
[Fact]
|
||||
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySQL(
|
||||
"Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;")
|
||||
.Options;
|
||||
var options = CreateMySqlOptions();
|
||||
using var db = new AppDbContext(options);
|
||||
|
||||
Assert.Contains(LatestMigration, db.Database.GetMigrations());
|
||||
@@ -28,9 +25,57 @@ public sealed class MySqlMigrationTests
|
||||
Assert.Contains("CREATE TABLE `GradeItems`", script);
|
||||
Assert.Contains("CREATE TABLE `EvaluationSetups`", script);
|
||||
Assert.Contains("CREATE TABLE `WarningRules`", script);
|
||||
Assert.Contains("ADD `CheckInMethod` int NOT NULL DEFAULT 1", script);
|
||||
Assert.Contains("CREATE UNIQUE INDEX `IX_AttendanceSheets_CheckInToken`", script);
|
||||
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
||||
Assert.Contains("DEFAULT 1", script);
|
||||
Assert.DoesNotContain("0001-01-01", script);
|
||||
Assert.DoesNotContain("0000-00-00", script);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MySql_date_properties_use_datetime_provider_conversion()
|
||||
{
|
||||
using var db = new AppDbContext(CreateMySqlOptions());
|
||||
|
||||
var dateProperties = db.Model.GetEntityTypes()
|
||||
.SelectMany(x => x.GetProperties())
|
||||
.Where(x => x.ClrType == typeof(DateOnly) ||
|
||||
x.ClrType == typeof(DateOnly?))
|
||||
.ToList();
|
||||
|
||||
Assert.NotEmpty(dateProperties);
|
||||
Assert.All(dateProperties, property =>
|
||||
{
|
||||
Assert.Equal("date", property.GetColumnType());
|
||||
Assert.Equal(
|
||||
typeof(DateTime),
|
||||
property.GetTypeMapping().Converter?.ProviderClrType);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MySql_guid_collections_use_provider_safe_predicates()
|
||||
{
|
||||
using var db = new AppDbContext(CreateMySqlOptions());
|
||||
var ids = new[]
|
||||
{
|
||||
Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
Guid.Parse("22222222-2222-2222-2222-222222222222")
|
||||
};
|
||||
|
||||
var sql = db.Colleges
|
||||
.WhereIn(ids, x => x.Id)
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(ids[0].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DbContextOptions<AppDbContext> CreateMySqlOptions() =>
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseMySQL(
|
||||
"Server=localhost;Database=jiaowu;User=__test__;Password=__not_used__;")
|
||||
.Options;
|
||||
}
|
||||
|
||||
@@ -74,8 +74,9 @@ public sealed class PersonnelControllerTests
|
||||
Assert.Equal(teacher.Name, user.DisplayName);
|
||||
Assert.Equal(teacher.CollegeId, user.CollegeId);
|
||||
Assert.True(await userManager.IsInRoleAsync(user, SystemRoles.Teacher));
|
||||
await db.Entry(teacher).ReloadAsync();
|
||||
Assert.Equal(user.Id, teacher.UserId);
|
||||
db.ChangeTracker.Clear();
|
||||
var linkedTeacher = await db.Teachers.SingleAsync(x => x.Id == teacher.Id);
|
||||
Assert.Equal(user.Id, linkedTeacher.UserId);
|
||||
}
|
||||
|
||||
private sealed class TestDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||
|
||||
@@ -4,6 +4,9 @@ using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
@@ -102,6 +105,206 @@ public sealed class ScheduleSettingsControllerTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_constraints_materializes_teachers_and_allowed_classrooms()
|
||||
{
|
||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
await using var db = new AppDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||
var course = new Course
|
||||
{
|
||||
Code = "CS101",
|
||||
Name = "程序设计基础",
|
||||
College = college,
|
||||
Credits = 4,
|
||||
TotalHours = 64,
|
||||
LectureHours = 48,
|
||||
PracticeHours = 16
|
||||
};
|
||||
var term = new AcademicTerm
|
||||
{
|
||||
Code = "2026-F",
|
||||
Name = "2026 秋季",
|
||||
AcademicYear = "2026-2027",
|
||||
Season = TermSeason.Autumn,
|
||||
StartDate = new DateOnly(2026, 9, 7),
|
||||
EndDate = new DateOnly(2027, 1, 17)
|
||||
};
|
||||
var teacher = new Teacher
|
||||
{
|
||||
TeacherNumber = "T001",
|
||||
Name = "测试教师",
|
||||
College = college
|
||||
};
|
||||
var classroom = new Classroom
|
||||
{
|
||||
Code = "J1-201",
|
||||
Name = "J1-201",
|
||||
Capacity = 80,
|
||||
Building = new Building
|
||||
{
|
||||
Code = "J1",
|
||||
Name = "第一教学楼",
|
||||
Campus = new Campus { Code = "MAIN", Name = "主校区" }
|
||||
}
|
||||
};
|
||||
var task = NewTask("TASK-01", course, term);
|
||||
task.Teachers.Add(new TeachingTaskTeacher
|
||||
{
|
||||
Teacher = teacher,
|
||||
IsPrimary = true
|
||||
});
|
||||
var constraint = new TeachingTaskScheduleConstraint
|
||||
{
|
||||
TeachingTask = task,
|
||||
AllowedDayOfWeeks = "1,3,5",
|
||||
EarliestPeriod = 1,
|
||||
LatestPeriod = 8,
|
||||
AllowedClassrooms =
|
||||
[
|
||||
new TeachingTaskAllowedClassroom { Classroom = classroom }
|
||||
]
|
||||
};
|
||||
db.AddRange(college, course, term, teacher, classroom, task, constraint);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var result = await new ScheduleSettingsController(db)
|
||||
.GetConstraints(term.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value);
|
||||
Assert.Single(payload.Cast<object>());
|
||||
var json = JsonSerializer.Serialize(
|
||||
ok.Value,
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
});
|
||||
Assert.Contains("测试教师", json);
|
||||
Assert.Contains(classroom.Id.ToString(), json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MySql_schedule_settings_materialize_time_slots_and_constraints()
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("JIAOWU_TEST_MYSQL_CONNECTION");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
return;
|
||||
|
||||
var previousConnectionString =
|
||||
Environment.GetEnvironmentVariable("ConnectionStrings__MySql");
|
||||
AppDbContext db;
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ConnectionStrings__MySql",
|
||||
connectionString);
|
||||
db = new AppDbContextFactory().CreateDbContext([]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ConnectionStrings__MySql",
|
||||
previousConnectionString);
|
||||
}
|
||||
await using var ownedDb = db;
|
||||
var term = await db.AcademicTerms.FirstAsync();
|
||||
var course = await db.Courses.FirstAsync();
|
||||
var teacher = await db.Teachers.FirstAsync(
|
||||
item => item.CollegeId == course.CollegeId);
|
||||
var classroom = await db.Classrooms.FirstAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
var periodNumber = await db.ScheduleTimeSlots
|
||||
.Where(item => item.AcademicTermId == term.Id)
|
||||
.Select(item => (int?)item.PeriodNumber)
|
||||
.MaxAsync() ?? 0;
|
||||
|
||||
var task = new TeachingTask
|
||||
{
|
||||
TaskNumber = $"MYSQL-{Guid.NewGuid():N}"[..22],
|
||||
Name = "MySQL 排课约束回归",
|
||||
AcademicTermId = term.Id,
|
||||
CourseId = course.Id,
|
||||
Capacity = 60,
|
||||
WeeklyHours = 2,
|
||||
StartWeek = 1,
|
||||
EndWeek = 16,
|
||||
Status = TeachingTaskStatus.Published,
|
||||
Teachers =
|
||||
[
|
||||
new TeachingTaskTeacher
|
||||
{
|
||||
TeacherId = teacher.Id,
|
||||
IsPrimary = true
|
||||
}
|
||||
]
|
||||
};
|
||||
db.Add(task);
|
||||
db.Add(new ScheduleTimeSlot
|
||||
{
|
||||
AcademicTermId = term.Id,
|
||||
PeriodNumber = periodNumber + 1,
|
||||
Name = "MySQL 回归节次",
|
||||
StartsAt = new TimeOnly(8, 0),
|
||||
EndsAt = new TimeOnly(8, 45)
|
||||
});
|
||||
db.Add(new TeachingTaskScheduleConstraint
|
||||
{
|
||||
TeachingTask = task,
|
||||
AllowedDayOfWeeks = "1,3,5",
|
||||
EarliestPeriod = 1,
|
||||
LatestPeriod = 8,
|
||||
AllowedClassrooms =
|
||||
[
|
||||
new TeachingTaskAllowedClassroom { ClassroomId = classroom.Id }
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
var result = await new ScheduleSettingsController(db)
|
||||
.GetConstraints(term.Id, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsAssignableFrom<IEnumerable>(ok.Value);
|
||||
Assert.Contains(payload.Cast<object>(), item =>
|
||||
JsonSerializer.Serialize(item).Contains(task.TaskNumber));
|
||||
var timeSlotsResult = await new ScheduleSettingsController(db)
|
||||
.GetTimeSlots(term.Id, CancellationToken.None);
|
||||
var timeSlotsOk = Assert.IsType<OkObjectResult>(timeSlotsResult);
|
||||
var timeSlotsJson = JsonSerializer.Serialize(timeSlotsOk.Value);
|
||||
Assert.Contains("08:00", timeSlotsJson);
|
||||
|
||||
var examTimeSlotsResult = await new ExamsController(db, null!, null!)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var examTimeSlotsOk =
|
||||
Assert.IsType<OkObjectResult>(examTimeSlotsResult);
|
||||
Assert.Contains(
|
||||
"08:00",
|
||||
JsonSerializer.Serialize(examTimeSlotsOk.Value));
|
||||
|
||||
var makeupTimeSlotsResult = await new MakeupExamsController(
|
||||
db,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!)
|
||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||
var makeupTimeSlotsOk =
|
||||
Assert.IsType<OkObjectResult>(makeupTimeSlotsResult);
|
||||
Assert.Contains(
|
||||
"08:00",
|
||||
JsonSerializer.Serialize(makeupTimeSlotsOk.Value));
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
|
||||
private static TeachingTask NewTask(
|
||||
string taskNumber,
|
||||
Course course,
|
||||
|
||||
Generated
+317
@@ -15,11 +15,13 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"pinia": "^4.0.2",
|
||||
"qrcode": "1.5.4",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
@@ -581,6 +583,16 @@
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
@@ -880,6 +892,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
@@ -936,6 +972,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
@@ -972,6 +1017,35 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1041,6 +1115,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1060,6 +1143,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
@@ -1126,6 +1215,12 @@
|
||||
"vue": "^3.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
@@ -1244,6 +1339,19 @@
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -1304,6 +1412,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1431,6 +1548,15 @@
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
@@ -1746,6 +1872,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -1903,6 +2041,42 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
@@ -1926,6 +2100,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -2003,6 +2186,15 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.22",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
||||
@@ -2040,6 +2232,23 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
@@ -2088,6 +2297,21 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
@@ -2139,6 +2363,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2158,6 +2388,32 @@
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
@@ -2599,6 +2855,67 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"pinia": "^4.0.2",
|
||||
"qrcode": "1.5.4",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
@@ -182,6 +182,160 @@ button { cursor: pointer; }
|
||||
.table-toolbar .el-input { width: min(340px, 60vw); }
|
||||
.table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
|
||||
.data-table { min-height: 360px; }
|
||||
|
||||
.el-select-dropdown__item.academic-term-option {
|
||||
transition: color 160ms ease, opacity 160ms ease, background-color 160ms ease;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.academic-term-option--current {
|
||||
color: #17694b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.academic-term-option--historical {
|
||||
color: #6f7d8c;
|
||||
opacity: .76;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.academic-term-option--archived {
|
||||
color: #98a2ad;
|
||||
opacity: .62;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item.academic-term-option--historical.is-hovering,
|
||||
.el-select-dropdown__item.academic-term-option--archived.is-hovering {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.academic-term-row--historical > .el-table__cell {
|
||||
color: #73808e;
|
||||
opacity: .78;
|
||||
}
|
||||
|
||||
.academic-term-row--archived > .el-table__cell {
|
||||
color: #8e98a3;
|
||||
background: #f8fafc;
|
||||
opacity: .66;
|
||||
}
|
||||
|
||||
.academic-term-row--historical:hover > .el-table__cell,
|
||||
.academic-term-row--archived:hover > .el-table__cell {
|
||||
opacity: .94;
|
||||
}
|
||||
|
||||
.historical-record {
|
||||
opacity: .68;
|
||||
filter: saturate(.72);
|
||||
}
|
||||
|
||||
.historical-record:hover,
|
||||
.historical-record.active {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.archived-record {
|
||||
opacity: .54;
|
||||
filter: grayscale(.18) saturate(.58);
|
||||
}
|
||||
|
||||
.archived-record:hover,
|
||||
.archived-record.active {
|
||||
opacity: .92;
|
||||
}
|
||||
|
||||
.term-policy-note {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin-left: 10px;
|
||||
color: #8994a1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-mobile-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.term-desktop-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.term-mobile-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.term-mobile-list article {
|
||||
border: 1px solid #e5e9ef;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.term-mobile-list article.academic-term-row--historical {
|
||||
color: #73808e;
|
||||
opacity: .78;
|
||||
}
|
||||
|
||||
.term-mobile-list article.academic-term-row--archived {
|
||||
color: #8e98a3;
|
||||
background: #f8fafc;
|
||||
opacity: .66;
|
||||
}
|
||||
|
||||
.term-mobile-list article:focus-within,
|
||||
.term-mobile-list article:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.term-mobile-list header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.term-mobile-list header span:first-child {
|
||||
color: #258779;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.term-mobile-list h3 {
|
||||
margin: 5px 0 3px;
|
||||
color: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.term-mobile-list p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-history-label {
|
||||
white-space: nowrap;
|
||||
color: #7c8895;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.term-mobile-list footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #edf0f4;
|
||||
}
|
||||
|
||||
.term-mobile-list footer .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
.el-table th.el-table__cell { color: #596274; background: #fafbfc; font-size: 12px; font-weight: 650; }
|
||||
.el-table .cell { font-size: 12px; }
|
||||
.table-status { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--teal); }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface AcademicTermLike {
|
||||
id: string
|
||||
name: string
|
||||
isCurrent?: boolean
|
||||
isArchived?: boolean
|
||||
isEnabled?: boolean
|
||||
hasPublishedTimetable?: boolean
|
||||
}
|
||||
|
||||
export function academicTermLabel(term: AcademicTermLike): string {
|
||||
if (term.isCurrent) return `${term.name} · 当前`
|
||||
if (term.isArchived) return `${term.name} · 已归档`
|
||||
return `${term.name} · 历史`
|
||||
}
|
||||
|
||||
export function academicTermOptionClass(term: AcademicTermLike): string {
|
||||
if (term.isCurrent) return 'academic-term-option academic-term-option--current'
|
||||
if (term.isArchived) return 'academic-term-option academic-term-option--archived'
|
||||
return 'academic-term-option academic-term-option--historical'
|
||||
}
|
||||
|
||||
export function defaultAcademicTermId(
|
||||
terms: AcademicTermLike[],
|
||||
requirePublishedTimetable = false,
|
||||
): string | undefined {
|
||||
const usable = requirePublishedTimetable
|
||||
? terms.filter((term) => term.hasPublishedTimetable)
|
||||
: terms
|
||||
return usable.find((term) => term.isCurrent)?.id
|
||||
?? usable.find((term) => !term.isArchived)?.id
|
||||
?? usable[0]?.id
|
||||
}
|
||||
|
||||
export function academicTermRowClass(term: {
|
||||
isCurrent?: boolean
|
||||
isArchived?: boolean
|
||||
}): string {
|
||||
if (term.isCurrent) return 'academic-term-row academic-term-row--current'
|
||||
if (term.isArchived) return 'academic-term-row academic-term-row--archived'
|
||||
return 'academic-term-row academic-term-row--historical'
|
||||
}
|
||||
@@ -1,9 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Close, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Close,
|
||||
DocumentChecked,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Switch,
|
||||
} from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface CourseOption {
|
||||
teachingTaskId: string
|
||||
courseId: string
|
||||
taskNumber: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
credits: number
|
||||
academicTermName: string
|
||||
teacherNames: string[]
|
||||
hasPublishedSchedule: boolean
|
||||
schedulingMode: 'Standard' | 'Flexible'
|
||||
}
|
||||
|
||||
interface GradeOption {
|
||||
courseId: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
credits: number
|
||||
totalScore: number | null
|
||||
gradePoint: number | null
|
||||
examStatus: string
|
||||
academicTermName: string
|
||||
taskNumber: string
|
||||
publishedAt: string | null
|
||||
}
|
||||
|
||||
interface SubstitutionTarget {
|
||||
courseId: string
|
||||
courseCode: string
|
||||
courseName: string
|
||||
credits: number
|
||||
source: 'assigned' | 'failed'
|
||||
detail: string
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isManager = computed(() => auth.user?.roles.some(r => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(r)) ?? false)
|
||||
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
|
||||
@@ -11,79 +55,259 @@ const isStudent = computed(() => auth.user?.roles.includes('Student') && !isMana
|
||||
|
||||
const pending = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tab = ref(isManager.value ? 'pending' : 'mine')
|
||||
const dialog = ref(false)
|
||||
const dialogType = ref('')
|
||||
const courseKeyword = ref('')
|
||||
const originalKeyword = ref('')
|
||||
const substituteKeyword = ref('')
|
||||
const rejectDialog = ref(false)
|
||||
const rejectItem = ref<any>(null)
|
||||
const rejectComment = ref('')
|
||||
|
||||
const enrolledCourses = ref<any[]>([])
|
||||
const enrolledCourses = ref<CourseOption[]>([])
|
||||
const gradeRecords = ref<any[]>([])
|
||||
const myGrades = ref<any[]>([])
|
||||
const myGrades = ref<GradeOption[]>([])
|
||||
const myExemptions = ref<any[]>([])
|
||||
const myDeferred = ref<any[]>([])
|
||||
const mySubstitutions = ref<any[]>([])
|
||||
|
||||
const form = reactive({ teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
|
||||
const form = reactive({
|
||||
teachingTaskId: '',
|
||||
reason: '',
|
||||
originalCourseId: '',
|
||||
substituteCourseId: '',
|
||||
gradeRecordId: '',
|
||||
requestedScore: 0,
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => ({
|
||||
exemption: '发起免修申请',
|
||||
deferred: '发起缓考申请',
|
||||
gradeMod: '成绩修改申请',
|
||||
substitution: '发起课程替代申请',
|
||||
}[dialogType.value] ?? '发起申请'))
|
||||
|
||||
const selectedCourse = computed(() =>
|
||||
enrolledCourses.value.find(course => course.teachingTaskId === form.teachingTaskId))
|
||||
|
||||
const filteredCourses = computed(() => {
|
||||
const keyword = courseKeyword.value.trim().toLocaleLowerCase()
|
||||
if (!keyword) return enrolledCourses.value
|
||||
return enrolledCourses.value.filter(course =>
|
||||
[course.courseCode, course.courseName, course.taskNumber, ...course.teacherNames]
|
||||
.some(value => value?.toLocaleLowerCase().includes(keyword)))
|
||||
})
|
||||
|
||||
function uniqueGrades(predicate: (grade: GradeOption) => boolean) {
|
||||
const result = new Map<string, GradeOption>()
|
||||
for (const grade of myGrades.value) {
|
||||
if (predicate(grade) && !result.has(grade.courseId)) result.set(grade.courseId, grade)
|
||||
}
|
||||
return [...result.values()]
|
||||
}
|
||||
|
||||
const failedGrades = computed(() =>
|
||||
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) < 60))
|
||||
const passedGrades = computed(() =>
|
||||
uniqueGrades(grade => grade.totalScore != null && Number(grade.totalScore) >= 60))
|
||||
|
||||
const substitutionTargets = computed<SubstitutionTarget[]>(() => {
|
||||
const result = new Map<string, SubstitutionTarget>()
|
||||
for (const course of enrolledCourses.value) {
|
||||
result.set(course.courseId, {
|
||||
courseId: course.courseId,
|
||||
courseCode: course.courseCode,
|
||||
courseName: course.courseName,
|
||||
credits: course.credits,
|
||||
source: 'assigned',
|
||||
detail: `${course.academicTermName} · ${course.taskNumber}`,
|
||||
})
|
||||
}
|
||||
for (const grade of failedGrades.value) {
|
||||
if (!result.has(grade.courseId)) {
|
||||
result.set(grade.courseId, {
|
||||
courseId: grade.courseId,
|
||||
courseCode: grade.courseCode,
|
||||
courseName: grade.courseName,
|
||||
credits: grade.credits,
|
||||
source: 'failed',
|
||||
detail: `${grade.academicTermName} · ${grade.totalScore} 分`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...result.values()]
|
||||
})
|
||||
|
||||
function matchesCourse(item: { courseCode: string; courseName: string }, keyword: string) {
|
||||
const normalized = keyword.trim().toLocaleLowerCase()
|
||||
return !normalized ||
|
||||
item.courseCode.toLocaleLowerCase().includes(normalized) ||
|
||||
item.courseName.toLocaleLowerCase().includes(normalized)
|
||||
}
|
||||
|
||||
function selectOriginal(courseId: string) {
|
||||
form.originalCourseId = courseId
|
||||
if (form.substituteCourseId === courseId) form.substituteCourseId = ''
|
||||
}
|
||||
|
||||
const filteredOriginalCourses = computed(() =>
|
||||
substitutionTargets.value.filter(item => matchesCourse(item, originalKeyword.value)))
|
||||
const filteredSubstituteCourses = computed(() =>
|
||||
passedGrades.value.filter(item => matchesCourse(item, substituteKeyword.value)))
|
||||
|
||||
const selectedOriginal = computed(() =>
|
||||
substitutionTargets.value.find(item => item.courseId === form.originalCourseId))
|
||||
const selectedSubstitute = computed(() =>
|
||||
passedGrades.value.find(item => item.courseId === form.substituteCourseId))
|
||||
|
||||
const recordCount = computed(() =>
|
||||
myExemptions.value.length + myDeferred.value.length + mySubstitutions.value.length)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isManager.value) pending.value = (await http.get('/approvals/pending')).data
|
||||
if (isStudent.value) {
|
||||
const [courses, grades, ex, df] = await Promise.all([
|
||||
http.get('/approvals/my-courses'), http.get('/approvals/my-grades'),
|
||||
http.get('/approvals/exemptions/mine'), http.get('/approvals/deferred/mine'),
|
||||
const [courses, grades, ex, df, substitutions] = await Promise.all([
|
||||
http.get('/approvals/my-courses'),
|
||||
http.get('/approvals/my-grades'),
|
||||
http.get('/approvals/exemptions/mine'),
|
||||
http.get('/approvals/deferred/mine'),
|
||||
http.get('/approvals/substitutions/mine'),
|
||||
])
|
||||
enrolledCourses.value = courses.data; myGrades.value = grades.data
|
||||
myExemptions.value = ex.data; myDeferred.value = df.data
|
||||
enrolledCourses.value = courses.data
|
||||
myGrades.value = grades.data
|
||||
myExemptions.value = ex.data
|
||||
myDeferred.value = df.data
|
||||
mySubstitutions.value = substitutions.data
|
||||
}
|
||||
if (isTeacher.value) gradeRecords.value = (await http.get('/approvals/grade-records')).data
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
finally { loading.value = false }
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(type: string) {
|
||||
dialogType.value = type
|
||||
Object.assign(form, { teachingTaskId: '', reason: '', originalCourseId: '', substituteCourseId: '', gradeRecordId: '', requestedScore: 0 })
|
||||
courseKeyword.value = ''
|
||||
originalKeyword.value = ''
|
||||
substituteKeyword.value = ''
|
||||
Object.assign(form, {
|
||||
teachingTaskId: '',
|
||||
reason: '',
|
||||
originalCourseId: '',
|
||||
substituteCourseId: '',
|
||||
gradeRecordId: '',
|
||||
requestedScore: 0,
|
||||
})
|
||||
dialog.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!form.reason.trim()) { ElMessage.warning('请填写原因'); return }
|
||||
if (!form.reason.trim()) {
|
||||
ElMessage.warning('请填写申请原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (dialogType.value === 'exemption') {
|
||||
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
|
||||
await http.post('/approvals/exemptions', { teachingTaskId: form.teachingTaskId, reason: form.reason })
|
||||
} else if (dialogType.value === 'deferred') {
|
||||
if (!form.teachingTaskId) { ElMessage.warning('请选择课程'); return }
|
||||
await http.post('/approvals/deferred', { teachingTaskId: form.teachingTaskId, reason: form.reason })
|
||||
} else if (dialogType.value === 'gradeMod') {
|
||||
if (!form.gradeRecordId) { ElMessage.warning('请选择成绩记录'); return }
|
||||
await http.post('/approvals/grade-modifications', { gradeRecordId: form.gradeRecordId, requestedScore: form.requestedScore, reason: form.reason })
|
||||
} else if (dialogType.value === 'substitution') {
|
||||
if (!form.originalCourseId || !form.substituteCourseId) { ElMessage.warning('请选择两门课程'); return }
|
||||
await http.post('/approvals/substitutions', { originalCourseId: form.originalCourseId, substituteCourseId: form.substituteCourseId, reason: form.reason })
|
||||
if (!form.teachingTaskId) {
|
||||
ElMessage.warning('请选择申请免修的课程')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/exemptions', {
|
||||
teachingTaskId: form.teachingTaskId,
|
||||
reason: form.reason,
|
||||
})
|
||||
} else if (dialogType.value === 'deferred') {
|
||||
if (!form.teachingTaskId) {
|
||||
ElMessage.warning('请选择申请缓考的课程')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/deferred', {
|
||||
teachingTaskId: form.teachingTaskId,
|
||||
reason: form.reason,
|
||||
})
|
||||
} else if (dialogType.value === 'gradeMod') {
|
||||
if (!form.gradeRecordId) {
|
||||
ElMessage.warning('请选择成绩记录')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/grade-modifications', {
|
||||
gradeRecordId: form.gradeRecordId,
|
||||
requestedScore: form.requestedScore,
|
||||
reason: form.reason,
|
||||
})
|
||||
} else if (dialogType.value === 'substitution') {
|
||||
if (!form.originalCourseId || !form.substituteCourseId) {
|
||||
ElMessage.warning('请完整选择被替代课程和替代课程')
|
||||
return
|
||||
}
|
||||
await http.post('/approvals/substitutions', {
|
||||
originalCourseId: form.originalCourseId,
|
||||
substituteCourseId: form.substituteCourseId,
|
||||
reason: form.reason,
|
||||
})
|
||||
}
|
||||
dialog.value = false
|
||||
ElMessage.success('申请已提交')
|
||||
await load()
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
dialog.value = false; ElMessage.success('申请已提交'); await load()
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
}
|
||||
|
||||
async function approveItem(item: any) {
|
||||
try {
|
||||
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
|
||||
if (ep) { await http.post(`${ep}/${item.id}/approve`); ElMessage.success('已通过'); await load() }
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
const ep = `/approvals/${item.type === 'CourseExemption' ? 'exemptions' : item.type === 'DeferredExam' ? 'deferred' : item.type === 'CourseSubstitution' ? 'substitutions' : ''}`
|
||||
if (ep) {
|
||||
await http.post(`${ep}/${item.id}/approve`)
|
||||
ElMessage.success('已通过')
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(item: any) { rejectItem.value = item; rejectComment.value = ''; rejectDialog.value = true }
|
||||
function openReject(item: any) {
|
||||
rejectItem.value = item
|
||||
rejectComment.value = ''
|
||||
rejectDialog.value = true
|
||||
}
|
||||
|
||||
async function rejectSubmit() {
|
||||
if (!rejectItem.value) return
|
||||
try {
|
||||
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`;
|
||||
if (ep) { await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value }); rejectDialog.value = false; ElMessage.success('已驳回'); await load() }
|
||||
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
const ep = `/approvals/${rejectItem.value.type === 'CourseExemption' ? 'exemptions' : rejectItem.value.type === 'DeferredExam' ? 'deferred' : rejectItem.value.type === 'CourseSubstitution' ? 'substitutions' : ''}`
|
||||
if (ep) {
|
||||
await http.post(`${ep}/${rejectItem.value.id}/reject`, { comment: rejectComment.value })
|
||||
rejectDialog.value = false
|
||||
ElMessage.success('已驳回')
|
||||
await load()
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(apiErrorMessage(e))
|
||||
}
|
||||
}
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === 'Submitted' ? '待审核' : status === 'Approved' ? '已通过' : '已驳回'
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
return status === 'Approved' ? 'success' : status === 'Rejected' ? 'danger' : 'warning'
|
||||
}
|
||||
|
||||
function courseScheduleText(course: CourseOption) {
|
||||
if (course.schedulingMode === 'Flexible') return '非排时课程'
|
||||
return course.hasPublishedSchedule ? '课表已发布' : '等待课表发布'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@@ -92,92 +316,419 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="page-stack appr-page">
|
||||
<section class="page-intro">
|
||||
<div><span class="section-kicker">APPROVAL CENTER</span><h2>审批中心</h2><p>学籍异动、调停课、成绩审核、考勤申诉、免修、缓考、成绩修改、课程替代。</p></div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('exemption')">免修</el-button>
|
||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('deferred')">缓考</el-button>
|
||||
<el-button v-if="isStudent" :icon="Plus" @click="openDialog('substitution')">课程替代</el-button>
|
||||
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<div>
|
||||
<span class="section-kicker">APPROVAL CENTER</span>
|
||||
<h2>审批中心</h2>
|
||||
<p>{{ isStudent ? '从实际修读课程发起申请,查看审核进度与处理结果。' : '集中处理教务申请与审核任务。' }}</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<el-segmented v-model="tab" :options="[
|
||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
||||
{ label: '我的记录', value: 'mine' },
|
||||
]" style="margin-bottom:16px" />
|
||||
<section v-if="isStudent" v-loading="loading" class="application-launcher">
|
||||
<button class="launch-card exemption" type="button" @click="openDialog('exemption')">
|
||||
<span class="launch-icon"><DocumentChecked /></span>
|
||||
<span class="launch-copy">
|
||||
<small>COURSE EXEMPTION</small>
|
||||
<b>申请免修</b>
|
||||
<em>从当前或已发布课表的修读课程中选择</em>
|
||||
</span>
|
||||
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
|
||||
</button>
|
||||
<button class="launch-card deferred" type="button" @click="openDialog('deferred')">
|
||||
<span class="launch-icon"><Clock /></span>
|
||||
<span class="launch-copy">
|
||||
<small>DEFERRED EXAM</small>
|
||||
<b>申请缓考</b>
|
||||
<em>课程与教学班信息一并提交</em>
|
||||
</span>
|
||||
<span class="launch-meta">{{ enrolledCourses.length }} 门可选 <Plus /></span>
|
||||
</button>
|
||||
<button class="launch-card substitution" type="button" @click="openDialog('substitution')">
|
||||
<span class="launch-icon"><Switch /></span>
|
||||
<span class="launch-copy">
|
||||
<small>COURSE SUBSTITUTION</small>
|
||||
<b>申请课程替代</b>
|
||||
<em>修读中或未通过课程 → 已通过课程</em>
|
||||
</span>
|
||||
<span class="launch-meta">{{ passedGrades.length }} 门可替代 <Plus /></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="approval-tabs">
|
||||
<el-segmented
|
||||
v-model="tab"
|
||||
:options="[
|
||||
...(isManager ? [{ label: `待审批 (${pending.length})`, value: 'pending' }] : []),
|
||||
{ label: `我的记录${isStudent ? ` (${recordCount})` : ''}`, value: 'mine' },
|
||||
]"
|
||||
/>
|
||||
<el-button v-if="isTeacher" :icon="Plus" type="primary" @click="openDialog('gradeMod')">成绩修改</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Pending -->
|
||||
<section v-if="tab === 'pending'" v-loading="loading" class="appr-list">
|
||||
<article v-for="item in pending" :key="`${item.type}-${item.id}`" class="appr-card">
|
||||
<el-tag size="small" :type="item.label === '考勤申诉' ? 'warning' : 'primary'">{{ item.label }}</el-tag>
|
||||
<div class="appr-body"><b>{{ item.title }}</b><p>{{ item.desc }}</p><small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small></div>
|
||||
<div class="appr-actions" v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)">
|
||||
<div class="appr-body">
|
||||
<b>{{ item.title }}</b>
|
||||
<p>{{ item.desc }}</p>
|
||||
<small>{{ item.college }} · {{ new Date(item.time).toLocaleString('zh-CN') }}</small>
|
||||
</div>
|
||||
<div v-if="['CourseExemption','DeferredExam','CourseSubstitution'].includes(item.type)" class="appr-actions">
|
||||
<el-button size="small" type="danger" plain :icon="Close" @click="openReject(item)">驳回</el-button>
|
||||
<el-button size="small" type="success" :icon="Check" @click="approveItem(item)">通过</el-button>
|
||||
</div>
|
||||
<div class="appr-actions" v-else><el-button size="small" text type="primary" @click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')">查看 →</el-button></div>
|
||||
<div v-else class="appr-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
@click="$router.push(item.type==='StudentStatusChange'?'/student-status-changes':item.type==='CourseAdjustment'?'/course-adjustments':item.type==='GradeSheet'?'/grades':'/teacher-attendance')"
|
||||
>
|
||||
查看 →
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!pending.length" description="暂无待审批" />
|
||||
</section>
|
||||
|
||||
<!-- My records -->
|
||||
<section v-if="tab === 'mine'" v-loading="loading">
|
||||
<template v-if="isStudent">
|
||||
<h4 style="margin-bottom:8px">免修申请</h4>
|
||||
<div class="appr-list" style="margin-bottom:16px">
|
||||
<article v-for="e in myExemptions" :key="e.id" class="appr-card"><el-tag size="small" :type="e.status === 'Approved' ? 'success' : e.status === 'Rejected' ? 'danger' : 'warning'">{{ e.status === 'Submitted' ? '待审核' : e.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ e.courseName }}</b><p>{{ e.reason }}</p><small v-if="e.reviewComment">{{ e.reviewComment }}</small></div></article>
|
||||
<el-empty v-if="!myExemptions.length" description="无" />
|
||||
<div v-if="recordCount" class="record-columns">
|
||||
<section class="record-group">
|
||||
<header><span class="record-mark exemption"></span><b>免修申请</b><em>{{ myExemptions.length }}</em></header>
|
||||
<div class="appr-list">
|
||||
<article v-for="item in myExemptions" :key="item.id" class="appr-card compact">
|
||||
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||
<div class="appr-body">
|
||||
<b>{{ item.courseName }}</b>
|
||||
<p>{{ item.reason }}</p>
|
||||
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||
</div>
|
||||
<h4 style="margin-bottom:8px">缓考申请</h4>
|
||||
<div class="appr-list" style="margin-bottom:16px">
|
||||
<article v-for="d in myDeferred" :key="d.id" class="appr-card"><el-tag size="small" :type="d.status === 'Approved' ? 'success' : d.status === 'Rejected' ? 'danger' : 'warning'">{{ d.status === 'Submitted' ? '待审核' : d.status === 'Approved' ? '已通过' : '已驳回' }}</el-tag><div class="appr-body"><b>{{ d.courseName }}</b><p>{{ d.reason }}</p></div></article>
|
||||
<el-empty v-if="!myDeferred.length" description="无" />
|
||||
</article>
|
||||
<p v-if="!myExemptions.length" class="record-empty">尚未提交免修申请</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="record-group">
|
||||
<header><span class="record-mark deferred"></span><b>缓考申请</b><em>{{ myDeferred.length }}</em></header>
|
||||
<div class="appr-list">
|
||||
<article v-for="item in myDeferred" :key="item.id" class="appr-card compact">
|
||||
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||
<div class="appr-body">
|
||||
<b>{{ item.courseName }}</b>
|
||||
<p>{{ item.reason }}</p>
|
||||
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!myDeferred.length" class="record-empty">尚未提交缓考申请</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="record-group substitution-records">
|
||||
<header><span class="record-mark substitution"></span><b>课程替代申请</b><em>{{ mySubstitutions.length }}</em></header>
|
||||
<div class="appr-list">
|
||||
<article v-for="item in mySubstitutions" :key="item.id" class="appr-card compact">
|
||||
<el-tag size="small" :type="statusType(item.status)">{{ statusText(item.status) }}</el-tag>
|
||||
<div class="appr-body">
|
||||
<b>{{ item.substituteCourseCode }} {{ item.substituteCourseName }} → {{ item.originalCourseCode }} {{ item.originalCourseName }}</b>
|
||||
<p>{{ item.reason }}</p>
|
||||
<small v-if="item.reviewComment">审核意见:{{ item.reviewComment }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!mySubstitutions.length" class="record-empty">尚未提交课程替代申请</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<el-empty v-else description="还没有申请记录">
|
||||
<el-button type="primary" @click="openDialog('exemption')">发起第一项申请</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Dialog -->
|
||||
<el-dialog v-model="dialog" :title="dialogType === 'exemption' ? '免修申请' : dialogType === 'deferred' ? '缓考申请' : dialogType === 'gradeMod' ? '成绩修改申请' : '课程替代申请'" width="560px">
|
||||
<el-dialog v-model="dialog" :title="dialogTitle" width="min(780px, calc(100vw - 24px))" class="application-dialog" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<template v-if="dialogType === 'exemption' || dialogType === 'deferred'">
|
||||
<el-form-item label="选择课程" required>
|
||||
<el-select v-model="form.teachingTaskId" filterable><el-option v-for="c in enrolledCourses" :key="c.teachingTaskId" :label="`${c.courseCode} ${c.courseName}`" :value="c.teachingTaskId" /></el-select>
|
||||
<div class="dialog-lead">
|
||||
<span :class="['dialog-lead-icon', dialogType]">
|
||||
<DocumentChecked v-if="dialogType === 'exemption'" />
|
||||
<Clock v-else />
|
||||
</span>
|
||||
<div>
|
||||
<b>{{ dialogType === 'exemption' ? '选择申请免修的教学班' : '选择申请缓考的教学班' }}</b>
|
||||
<p>列表包含当前学期课程,以及仍未归档且课表已发布的行政班或已选课程。</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="课程" required>
|
||||
<el-input v-model="courseKeyword" :prefix-icon="Search" clearable placeholder="搜索课程名称、代码、教学班号或教师" />
|
||||
<div v-if="filteredCourses.length" class="course-picker">
|
||||
<button
|
||||
v-for="course in filteredCourses"
|
||||
:key="course.teachingTaskId"
|
||||
type="button"
|
||||
:class="['course-choice', { selected: form.teachingTaskId === course.teachingTaskId }]"
|
||||
@click="form.teachingTaskId = course.teachingTaskId"
|
||||
>
|
||||
<span class="course-code">{{ course.courseCode }}</span>
|
||||
<span class="course-main">
|
||||
<b>{{ course.courseName }}</b>
|
||||
<small>{{ course.taskNumber }} · {{ course.academicTermName }}</small>
|
||||
<em>{{ course.teacherNames.length ? course.teacherNames.join('、') : '教师待定' }} · {{ course.credits }} 学分</em>
|
||||
</span>
|
||||
<span :class="['schedule-state', { ready: course.hasPublishedSchedule || course.schedulingMode === 'Flexible' }]">
|
||||
{{ courseScheduleText(course) }}
|
||||
</span>
|
||||
<span class="choice-check"><Check /></span>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else
|
||||
:description="enrolledCourses.length ? '没有匹配的课程' : '暂无可申请课程'"
|
||||
:image-size="76"
|
||||
>
|
||||
<p v-if="!enrolledCourses.length" class="empty-explanation">
|
||||
课程需为已发布教学任务,并分配到您的行政班或已完成选课;当前学期课程或已发布课表的课程均会显示。
|
||||
</p>
|
||||
</el-empty>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="dialogType === 'substitution'">
|
||||
<el-form-item label="未通过课程(被替代)" required>
|
||||
<el-select v-model="form.originalCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) < 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="已通过课程(替代课程)" required>
|
||||
<el-select v-model="form.substituteCourseId" filterable><el-option v-for="g in myGrades.filter((x:any) => Number(x.totalScore) >= 60)" :key="g.courseId" :label="`${g.courseCode} ${g.courseName} (${g.totalScore}分)`" :value="g.courseId" /></el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
title="替代关系须同时满足两个条件"
|
||||
description="被替代课程应为当前或已发布课表的修读课程,或已有未通过成绩的课程;替代课程必须已有正式发布且不低于 60 分的成绩。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="substitution-flow">
|
||||
<section class="substitution-column">
|
||||
<header><span>1</span><div><b>被替代课程</b><small>修读课程 / 历史未通过</small></div></header>
|
||||
<el-input v-model="originalKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
|
||||
<div v-if="filteredOriginalCourses.length" class="mini-course-list">
|
||||
<button
|
||||
v-for="course in filteredOriginalCourses"
|
||||
:key="course.courseId"
|
||||
type="button"
|
||||
:class="{ selected: form.originalCourseId === course.courseId }"
|
||||
@click="selectOriginal(course.courseId)"
|
||||
>
|
||||
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.detail }} · {{ course.credits }} 学分</small></span>
|
||||
<el-tag size="small" :type="course.source === 'assigned' ? 'primary' : 'danger'">
|
||||
{{ course.source === 'assigned' ? '修读' : '未通过' }}
|
||||
</el-tag>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty v-else :image-size="62" description="暂无可被替代课程" />
|
||||
</section>
|
||||
<div class="flow-arrow">→</div>
|
||||
<section class="substitution-column">
|
||||
<header><span>2</span><div><b>替代课程</b><small>已发布且成绩及格</small></div></header>
|
||||
<el-input v-model="substituteKeyword" :prefix-icon="Search" clearable placeholder="搜索课程" />
|
||||
<div v-if="filteredSubstituteCourses.length" class="mini-course-list">
|
||||
<button
|
||||
v-for="course in filteredSubstituteCourses"
|
||||
:key="course.courseId"
|
||||
type="button"
|
||||
:disabled="course.courseId === form.originalCourseId"
|
||||
:class="{ selected: form.substituteCourseId === course.courseId }"
|
||||
@click="form.substituteCourseId = course.courseId"
|
||||
>
|
||||
<span><b>{{ course.courseCode }} · {{ course.courseName }}</b><small>{{ course.academicTermName }} · {{ course.credits }} 学分</small></span>
|
||||
<strong>{{ course.totalScore }}<small>分</small></strong>
|
||||
</button>
|
||||
</div>
|
||||
<el-empty v-else :image-size="62" description="暂无已通过课程">
|
||||
<p class="empty-explanation">已排课但尚未发布成绩的课程不属于可替代课程。</p>
|
||||
</el-empty>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="dialogType === 'gradeMod'">
|
||||
<el-form-item label="成绩记录" required>
|
||||
<el-select v-model="form.gradeRecordId" filterable><el-option v-for="r in gradeRecords" :key="r.id" :label="`${r.studentName}(${r.studentNumber}) — ${r.courseName} — ${r.totalScore}分`" :value="r.id" /></el-select>
|
||||
<el-select v-model="form.gradeRecordId" filterable placeholder="选择已发布成绩">
|
||||
<el-option
|
||||
v-for="record in gradeRecords"
|
||||
:key="record.id"
|
||||
:label="`${record.studentName}(${record.studentNumber}) — ${record.courseName} — ${record.totalScore}分`"
|
||||
:value="record.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="修改为" required>
|
||||
<el-input-number v-model="form.requestedScore" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="修改为" required><el-input-number v-model="form.requestedScore" :min="0" :max="100" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="原因" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
|
||||
<el-form-item class="reason-field" label="申请原因" required>
|
||||
<el-input
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:placeholder="dialogType === 'deferred' ? '请说明无法按时参加考试的原因及相关情况' : dialogType === 'substitution' ? '请说明两门课程在内容、学分或培养要求上的对应关系' : '请说明申请依据及相关情况'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="submitForm">提交申请</el-button></template>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<span v-if="selectedCourse">{{ selectedCourse.courseCode }} · {{ selectedCourse.courseName }}</span>
|
||||
<span v-else-if="selectedOriginal || selectedSubstitute">
|
||||
{{ selectedSubstitute?.courseName ?? '请选择替代课程' }} → {{ selectedOriginal?.courseName ?? '请选择被替代课程' }}
|
||||
</span>
|
||||
<el-button @click="dialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">提交申请</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Reject -->
|
||||
<el-dialog v-model="rejectDialog" title="驳回" width="460px">
|
||||
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="驳回原因" />
|
||||
<template #footer><el-button @click="rejectDialog = false">取消</el-button><el-button type="danger" @click="rejectSubmit">确认驳回</el-button></template>
|
||||
<el-dialog v-model="rejectDialog" title="驳回申请" width="min(460px, calc(100vw - 24px))">
|
||||
<el-input v-model="rejectComment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写驳回原因" />
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialog = false">取消</el-button>
|
||||
<el-button type="danger" @click="rejectSubmit">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.application-launcher {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.launch-card {
|
||||
position: relative;
|
||||
min-height: 132px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
color: #1e293b;
|
||||
background: linear-gradient(145deg, #fff 48%, #f6f8fb);
|
||||
border: 1px solid #dfe4eb;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
|
||||
.launch-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -26px;
|
||||
bottom: -50px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 18px solid rgba(67, 97, 238, .05);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.launch-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: #aebbd0;
|
||||
box-shadow: 0 12px 28px rgba(30, 41, 59, .08);
|
||||
}
|
||||
|
||||
.launch-icon, .dialog-lead-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #3556b5;
|
||||
background: #edf2ff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.launch-icon svg, .dialog-lead-icon svg { width: 21px; }
|
||||
.deferred .launch-icon, .dialog-lead-icon.deferred { color: #a85d00; background: #fff3dc; }
|
||||
.substitution .launch-icon { color: #087f5b; background: #e8f8f1; }
|
||||
.launch-copy { display: grid; gap: 3px; min-width: 0; }
|
||||
.launch-copy small { color: #8791a3; font: 600 9px/1.2 ui-monospace, monospace; letter-spacing: .1em; }
|
||||
.launch-copy b { margin-top: 3px; font-size: 17px; }
|
||||
.launch-copy em { color: #667085; font-size: 11px; font-style: normal; line-height: 1.5; }
|
||||
.launch-meta { position: absolute; left: 76px; bottom: 17px; display: flex; align-items: center; gap: 5px; color: #506079; font-size: 10px; }
|
||||
.launch-meta svg { width: 12px; }
|
||||
|
||||
.approval-tabs { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.appr-list { display: grid; gap: 8px; }
|
||||
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; }
|
||||
.appr-card { display: flex; align-items: center; gap: 14px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 10px; }
|
||||
.appr-card.compact { align-items: flex-start; padding: 13px; }
|
||||
.appr-body { flex: 1; min-width: 0; }
|
||||
.appr-body b { font-size: 14px; display: block; margin-bottom: 4px; }
|
||||
.appr-body p { font-size: 13px; color: #606266; margin: 0 0 4px; }
|
||||
.appr-body small { font-size: 11px; color: var(--muted); }
|
||||
.appr-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.appr-body b { display: block; margin-bottom: 4px; font-size: 13px; }
|
||||
.appr-body p { margin: 0 0 4px; color: #606266; font-size: 12px; line-height: 1.55; }
|
||||
.appr-body small { color: var(--muted); font-size: 10px; }
|
||||
.appr-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
|
||||
.record-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||
.record-group { padding: 16px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
|
||||
.record-group.substitution-records { grid-column: 1 / -1; }
|
||||
.record-group > header { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.record-group > header b { font-size: 13px; }
|
||||
.record-group > header em { margin-left: auto; min-width: 22px; padding: 3px 7px; color: #667085; background: #fff; border-radius: 10px; font-size: 10px; font-style: normal; text-align: center; }
|
||||
.record-mark { width: 8px; height: 8px; background: #4b6fd8; border-radius: 50%; box-shadow: 0 0 0 4px #e8edfb; }
|
||||
.record-mark.deferred { background: #d5841c; box-shadow: 0 0 0 4px #fff0d8; }
|
||||
.record-mark.substitution { background: #13916a; box-shadow: 0 0 0 4px #dff5ed; }
|
||||
.record-empty { margin: 20px 0; color: #98a2b3; font-size: 11px; text-align: center; }
|
||||
|
||||
.dialog-lead { margin-bottom: 18px; padding: 14px; display: flex; align-items: center; gap: 12px; background: #f7f9fc; border: 1px solid #e6eaf1; border-radius: 10px; }
|
||||
.dialog-lead b { display: block; margin-bottom: 3px; font-size: 13px; }
|
||||
.dialog-lead p { margin: 0; color: #667085; font-size: 11px; }
|
||||
.course-picker { width: 100%; max-height: 330px; margin-top: 10px; display: grid; gap: 8px; overflow: auto; }
|
||||
.course-choice { width: 100%; padding: 13px; display: grid; grid-template-columns: 68px minmax(0, 1fr) auto 20px; align-items: center; gap: 12px; text-align: left; background: #fff; border: 1px solid #e1e6ee; border-radius: 10px; cursor: pointer; transition: border-color .15s, background .15s; }
|
||||
.course-choice:hover { border-color: #9aabd4; }
|
||||
.course-choice.selected { background: #f4f7ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
|
||||
.course-code { color: #3651a3; font: 700 11px/1.3 ui-monospace, monospace; }
|
||||
.course-main { display: grid; min-width: 0; gap: 3px; }
|
||||
.course-main b { color: #1e293b; font-size: 13px; }
|
||||
.course-main small, .course-main em { overflow: hidden; color: #7b8495; font-size: 10px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.schedule-state { padding: 4px 7px; color: #8a6116; background: #fff4d9; border-radius: 6px; font-size: 9px; }
|
||||
.schedule-state.ready { color: #087f5b; background: #e5f7ef; }
|
||||
.choice-check { display: grid; place-items: center; width: 18px; height: 18px; color: transparent; border: 1px solid #cfd5df; border-radius: 50%; }
|
||||
.choice-check svg { width: 11px; }
|
||||
.course-choice.selected .choice-check { color: #fff; background: #4b6fd8; border-color: #4b6fd8; }
|
||||
.empty-explanation { max-width: 450px; margin: -10px auto 0; color: #8992a3; font-size: 10px; line-height: 1.6; }
|
||||
|
||||
.substitution-flow { margin-top: 16px; display: grid; grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr); align-items: stretch; gap: 10px; }
|
||||
.substitution-column { min-width: 0; padding: 14px; background: #f8fafc; border: 1px solid #e5e9f0; border-radius: 12px; }
|
||||
.substitution-column > header { margin-bottom: 12px; display: flex; align-items: center; gap: 9px; }
|
||||
.substitution-column > header > span { width: 25px; height: 25px; display: grid; place-items: center; color: #fff; background: #405fb7; border-radius: 8px; font: 700 11px/1 ui-monospace, monospace; }
|
||||
.substitution-column > header div { display: grid; gap: 2px; }
|
||||
.substitution-column > header b { font-size: 12px; }
|
||||
.substitution-column > header small { color: #8a94a5; font-size: 9px; }
|
||||
.flow-arrow { display: grid; place-items: center; color: #7d8fbf; font-size: 18px; }
|
||||
.mini-course-list { max-height: 255px; margin-top: 9px; display: grid; align-content: start; gap: 6px; overflow: auto; }
|
||||
.mini-course-list button { padding: 10px; display: flex; align-items: center; gap: 8px; text-align: left; background: #fff; border: 1px solid #e2e7ee; border-radius: 8px; cursor: pointer; }
|
||||
.mini-course-list button:hover { border-color: #a9b5d3; }
|
||||
.mini-course-list button.selected { background: #f0f4ff; border-color: #4b6fd8; box-shadow: inset 3px 0 #4b6fd8; }
|
||||
.mini-course-list button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.mini-course-list button > span { min-width: 0; display: grid; flex: 1; gap: 3px; }
|
||||
.mini-course-list button b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mini-course-list button span small { color: #8a94a5; font-size: 9px; }
|
||||
.mini-course-list button > strong { color: #087f5b; font-size: 16px; }
|
||||
.mini-course-list button > strong small { margin-left: 1px; font-size: 8px; font-weight: 500; }
|
||||
.reason-field { margin-top: 18px; }
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.dialog-footer > span { margin-right: auto; overflow: hidden; color: #667085; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.application-launcher { grid-template-columns: 1fr; }
|
||||
.launch-card { min-height: 112px; }
|
||||
.record-columns { grid-template-columns: 1fr; }
|
||||
.record-group.substitution-records { grid-column: auto; }
|
||||
.substitution-flow { grid-template-columns: 1fr; }
|
||||
.flow-arrow { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-intro { align-items: flex-start; }
|
||||
.approval-tabs { align-items: flex-start; }
|
||||
.course-choice { grid-template-columns: 58px minmax(0, 1fr) 18px; }
|
||||
.schedule-state { grid-column: 2; justify-self: start; }
|
||||
.appr-card { align-items: flex-start; flex-wrap: wrap; }
|
||||
.appr-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,19 @@ import { useRoute } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermRowClass } from '../utils/academicTerms'
|
||||
|
||||
type Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms' | 'course-categories'
|
||||
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
|
||||
interface Row {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
academicYear?: string
|
||||
isCurrent?: boolean
|
||||
isArchived?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const allTabs: { key: Kind; label: string; hint: string }[] = [
|
||||
{ key: 'campuses', label: '校区', hint: '学校的物理校区' },
|
||||
@@ -210,6 +220,47 @@ async function remove(row: any) {
|
||||
}
|
||||
}
|
||||
|
||||
function termTableRowClass({ row }: { row: any }) {
|
||||
return active.value === 'terms' ? academicTermRowClass(row) : ''
|
||||
}
|
||||
|
||||
async function setCurrentTerm(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将“${row.name}”切换为当前学期?其他学期会自动转为历史显示,但不会自动归档,也不会冻结成绩和补考。`,
|
||||
'切换当前学期',
|
||||
{ type: 'warning', confirmButtonText: '切换为当前', cancelButtonText: '取消' },
|
||||
)
|
||||
await http.post(`/base-data/terms/${row.id}/set-current`)
|
||||
ElMessage.success(`当前学期已切换为“${row.name}”`)
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTermArchive(row: any) {
|
||||
const restoring = row.isArchived
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
restoring
|
||||
? `撤销“${row.name}”的归档状态?撤销后仍作为历史学期显示,可再次设为当前。`
|
||||
: `归档“${row.name}”?归档只降低历史数据的显示优先级,不会冻结成绩更正或补考成绩录入。`,
|
||||
restoring ? '撤销学期归档' : '归档学期',
|
||||
{
|
||||
type: restoring ? 'info' : 'warning',
|
||||
confirmButtonText: restoring ? '撤销归档' : '确认归档',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
await http.post(`/base-data/terms/${row.id}/${restoring ? 'unarchive' : 'archive'}`)
|
||||
ElMessage.success(restoring ? '已撤销归档' : '学期已归档')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(), loadReferences()])
|
||||
})
|
||||
@@ -276,7 +327,65 @@ watch(
|
||||
<span>共 {{ filteredRows.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="filteredRows" class="data-table">
|
||||
<el-alert
|
||||
v-if="active === 'terms'"
|
||||
class="term-policy-note"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="切换当前学期只改变默认工作学期;归档需手动执行,归档后仍可处理补考与成绩更正。"
|
||||
/>
|
||||
|
||||
<div v-if="active === 'terms'" class="term-mobile-list">
|
||||
<article
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
:class="academicTermRowClass(row)"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span>{{ row.code }}</span>
|
||||
<h3>{{ row.name }}</h3>
|
||||
<p>{{ row.academicYear }}</p>
|
||||
</div>
|
||||
<el-tag v-if="row.isCurrent" type="success">当前</el-tag>
|
||||
<el-tag v-else-if="row.isArchived" type="info">已归档</el-tag>
|
||||
<span v-else class="term-history-label">历史学期</span>
|
||||
</header>
|
||||
<footer v-if="canManage">
|
||||
<el-button
|
||||
v-if="!row.isCurrent && !row.isArchived"
|
||||
size="small"
|
||||
type="success"
|
||||
plain
|
||||
@click="setCurrentTerm(row)"
|
||||
>设为当前</el-button>
|
||||
<el-button
|
||||
v-if="!row.isCurrent"
|
||||
size="small"
|
||||
:type="row.isArchived ? 'primary' : 'warning'"
|
||||
plain
|
||||
@click="toggleTermArchive(row)"
|
||||
>{{ row.isArchived ? '撤销归档' : '归档' }}</el-button>
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="!row.isCurrent"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="remove(row)"
|
||||
>删除</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredRows"
|
||||
:row-class-name="termTableRowClass"
|
||||
class="data-table"
|
||||
:class="{ 'term-desktop-table': active === 'terms' }"
|
||||
>
|
||||
<el-table-column prop="code" label="编码" min-width="130" />
|
||||
<el-table-column prop="name" label="名称" min-width="180" />
|
||||
<el-table-column v-if="active === 'colleges'" prop="campusName" label="所属校区" min-width="140" />
|
||||
@@ -288,8 +397,12 @@ watch(
|
||||
<template #default="{ row }">{{ row.counselorName || '未分配' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
|
||||
<el-table-column v-if="active === 'terms'" label="当前学期" width="100">
|
||||
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else>—</span></template>
|
||||
<el-table-column v-if="active === 'terms'" label="运行状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isCurrent" type="success">当前</el-tag>
|
||||
<el-tag v-else-if="row.isArchived" type="info">已归档</el-tag>
|
||||
<span v-else>历史学期</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="active === 'buildings'" prop="campusName" label="所属校区" min-width="140" />
|
||||
<el-table-column v-if="active === 'classrooms'" prop="buildingName" label="教学楼" min-width="130" />
|
||||
@@ -299,10 +412,34 @@ watch(
|
||||
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canManage" label="操作" width="150" fixed="right">
|
||||
<el-table-column
|
||||
v-if="canManage"
|
||||
label="操作"
|
||||
:width="active === 'terms' ? 280 : 150"
|
||||
fixed="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<template v-if="active === 'terms'">
|
||||
<el-button
|
||||
v-if="!row.isCurrent && !row.isArchived"
|
||||
link
|
||||
type="success"
|
||||
@click="setCurrentTerm(row)"
|
||||
>设为当前</el-button>
|
||||
<el-button
|
||||
v-if="!row.isCurrent"
|
||||
link
|
||||
:type="row.isArchived ? 'primary' : 'warning'"
|
||||
@click="toggleTermArchive(row)"
|
||||
>{{ row.isArchived ? '撤销归档' : '归档' }}</el-button>
|
||||
</template>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||||
<el-button
|
||||
v-if="active !== 'terms' || !row.isCurrent"
|
||||
link
|
||||
type="danger"
|
||||
@click="remove(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无数据,点击右上角开始新增" /></template>
|
||||
@@ -366,7 +503,12 @@ watch(
|
||||
<el-form-item label="开始日期"><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="结束日期"><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item><el-checkbox v-model="form.isCurrent">设为当前学期</el-checkbox></el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.isCurrent" :disabled="form.isArchived">
|
||||
设为当前学期
|
||||
</el-checkbox>
|
||||
<span v-if="form.isArchived" class="field-help">已归档学期需先撤销归档。</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-if="active === 'classrooms'" label="所属教学楼" required>
|
||||
<el-select v-model="form.buildingId">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Bell, Check, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isManager = computed(() =>
|
||||
@@ -172,7 +173,7 @@ onMounted(async () => {
|
||||
])
|
||||
terms.value = termRes.data
|
||||
tasks.value = taskRes.data.items
|
||||
termId.value = terms.value.find(t => t.isCurrent)?.id
|
||||
termId.value = defaultAcademicTermId(terms.value)
|
||||
} catch (_) {}
|
||||
await load()
|
||||
})
|
||||
@@ -204,7 +205,7 @@ function showCancel(type: string) { return type === 'Cancel' }
|
||||
|
||||
<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-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
|
||||
</el-select>
|
||||
<el-segmented v-model="tab" :options="[
|
||||
...(isTeacher ? [{ label: '我的申请', value: 'mine' }] : []),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const managerRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin']
|
||||
@@ -309,6 +310,8 @@ async function loadRounds(keepSelection = true) {
|
||||
rounds.value = (await http.get('/course-selections/rounds')).data
|
||||
const previousId = keepSelection ? selectedRound.value?.id : undefined
|
||||
const preferred = rounds.value.find((item) => item.id === previousId)
|
||||
?? rounds.value.find((item) => item.isAvailableNow && item.termIsCurrent)
|
||||
?? rounds.value.find((item) => item.termIsCurrent)
|
||||
?? rounds.value.find((item) => item.isAvailableNow)
|
||||
?? rounds.value[0]
|
||||
if (preferred) await selectRound(preferred)
|
||||
@@ -362,12 +365,11 @@ async function loadTasks(academicTermId: string) {
|
||||
|
||||
function openRound(round?: any) {
|
||||
editingRoundId.value = round?.id ?? ''
|
||||
const currentTerm = terms.value.find((item) => item.isCurrent)
|
||||
const now = new Date()
|
||||
const ends = new Date(now.getTime() + 7 * 86400000)
|
||||
const withdrawal = new Date(now.getTime() + 14 * 86400000)
|
||||
Object.assign(roundForm, {
|
||||
academicTermId: round?.academicTermId ?? currentTerm?.id,
|
||||
academicTermId: round?.academicTermId ?? defaultAcademicTermId(terms.value),
|
||||
name: round?.name ?? '',
|
||||
startsAt: round ? toPickerValue(round.startsAt) : toPickerValue(now.toISOString()),
|
||||
endsAt: round ? toPickerValue(round.endsAt) : toPickerValue(ends.toISOString()),
|
||||
@@ -719,7 +721,11 @@ onMounted(async () => {
|
||||
v-for="round in rounds"
|
||||
:key="round.id"
|
||||
type="button"
|
||||
:class="{ active: selectedRound?.id === round.id }"
|
||||
:class="{
|
||||
active: selectedRound?.id === round.id,
|
||||
'historical-record': !round.termIsCurrent && !round.termIsArchived,
|
||||
'archived-record': round.termIsArchived,
|
||||
}"
|
||||
@click="selectRound(round)"
|
||||
>
|
||||
<span>{{ round.termName }}</span>
|
||||
@@ -1056,7 +1062,7 @@ onMounted(async () => {
|
||||
<div class="form-grid">
|
||||
<el-form-item label="开课学期" required>
|
||||
<el-select v-model="roundForm.academicTermId">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="批次名称" required>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Delete, Edit, Lock, Plus, Unlock } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, academicTermRowClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const roles = computed(() => auth.user?.roles ?? [])
|
||||
@@ -31,7 +32,7 @@ function addDim() { setupForm.dimensions.push({ name: '', maxScore: 10 }) }
|
||||
function removeDim(i: number) { setupForm.dimensions.splice(i, 1) }
|
||||
|
||||
function resetSetupForm() {
|
||||
setupForm.academicTermId = undefined
|
||||
setupForm.academicTermId = defaultAcademicTermId(terms.value)
|
||||
setupForm.name = ''
|
||||
setupForm.startsAt = ''
|
||||
setupForm.endsAt = ''
|
||||
@@ -43,6 +44,13 @@ function resetSetupForm() {
|
||||
]
|
||||
}
|
||||
|
||||
function setupRowClass({ row }: { row: any }) {
|
||||
return academicTermRowClass({
|
||||
isCurrent: row.termIsCurrent,
|
||||
isArchived: row.termIsArchived,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadSetups() {
|
||||
setupLoading.value = true
|
||||
try { setups.value = (await http.get('/evaluations/setups')).data }
|
||||
@@ -207,7 +215,7 @@ onMounted(async () => {
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
|
||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">新建方案</el-button>
|
||||
</div>
|
||||
<el-table :data="setups" size="small" empty-text="暂无评教方案">
|
||||
<el-table :data="setups" :row-class-name="setupRowClass" size="small" empty-text="暂无评教方案">
|
||||
<el-table-column prop="name" label="方案名称" min-width="200" />
|
||||
<el-table-column prop="termName" label="学期" width="140" />
|
||||
<el-table-column label="状态" width="90">
|
||||
@@ -273,7 +281,7 @@ onMounted(async () => {
|
||||
<div class="form-grid">
|
||||
<el-form-item label="学期">
|
||||
<el-select v-model="setupForm.academicTermId" placeholder="选择学期" :disabled="!!editingSetup">
|
||||
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" />
|
||||
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="方案名称">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Promotion, Refresh, UserFilled, Setting } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isManager = computed(() =>
|
||||
@@ -53,7 +54,9 @@ async function load() {
|
||||
return
|
||||
}
|
||||
plans.value = (await http.get('/exams/plans')).data
|
||||
const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0]
|
||||
const plan = plans.value.find((x) => x.id === selected.value?.id)
|
||||
?? plans.value.find((x) => x.termIsCurrent)
|
||||
?? plans.value[0]
|
||||
if (plan) await selectPlan(plan.id)
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
finally { loading.value = false }
|
||||
@@ -63,7 +66,7 @@ async function selectPlan(id: string) {
|
||||
}
|
||||
function openPlan() {
|
||||
Object.assign(planForm, {
|
||||
academicTermId: terms.value.find((x) => x.isCurrent)?.id,
|
||||
academicTermId: defaultAcademicTermId(terms.value),
|
||||
name: '', notes: '',
|
||||
})
|
||||
planDialog.value = true
|
||||
@@ -196,7 +199,7 @@ onMounted(async () => {
|
||||
|
||||
<template v-if="isManager">
|
||||
<section class="exam-plan-strip">
|
||||
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)">
|
||||
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
|
||||
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
|
||||
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
|
||||
</button>
|
||||
@@ -271,7 +274,7 @@ onMounted(async () => {
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="学期">
|
||||
<el-select v-model="planForm.academicTermId">
|
||||
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" />
|
||||
<el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Location, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { academicTermLabel, academicTermOptionClass } from '../utils/academicTerms'
|
||||
|
||||
interface TermOption {
|
||||
id: string
|
||||
@@ -9,6 +10,7 @@ interface TermOption {
|
||||
startDate: string
|
||||
endDate: string
|
||||
isCurrent: boolean
|
||||
isArchived: boolean
|
||||
hasPublishedTimetable: boolean
|
||||
}
|
||||
|
||||
@@ -205,8 +207,9 @@ onMounted(async () => {
|
||||
<el-option
|
||||
v-for="term in terms"
|
||||
:key="term.id"
|
||||
:label="term.name"
|
||||
:label="academicTermLabel(term)"
|
||||
:value="term.id"
|
||||
:class="academicTermOptionClass(term)"
|
||||
:disabled="!term.hasPublishedTimetable"
|
||||
>
|
||||
<span>{{ term.name }}</span>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
|
||||
@@ -351,7 +352,7 @@ function calcPreviewTotal(record: any): number | null {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item) => item.isCurrent)?.id
|
||||
termId.value = defaultAcademicTermId(terms.value)
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
@@ -381,7 +382,7 @@ onMounted(async () => {
|
||||
<label>
|
||||
<span>查看学期</span>
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||||
</el-select>
|
||||
</label>
|
||||
</section>
|
||||
@@ -421,7 +422,7 @@ onMounted(async () => {
|
||||
<template v-else>
|
||||
<section class="grade-toolbar">
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||||
</el-select>
|
||||
<el-select v-model="status" clearable placeholder="全部状态" @change="load">
|
||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isManager = computed(() =>
|
||||
@@ -66,7 +67,9 @@ async function load() {
|
||||
return
|
||||
}
|
||||
plans.value = (await http.get('/makeup-exams/plans')).data
|
||||
const plan = plans.value.find((x) => x.id === selected.value?.id) ?? plans.value[0]
|
||||
const plan = plans.value.find((x) => x.id === selected.value?.id)
|
||||
?? plans.value.find((x) => x.termIsCurrent)
|
||||
?? plans.value[0]
|
||||
if (plan) await selectPlan(plan.id)
|
||||
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
|
||||
finally { loading.value = false }
|
||||
@@ -76,7 +79,7 @@ async function selectPlan(id: string) {
|
||||
}
|
||||
function openPlan() {
|
||||
Object.assign(planForm, {
|
||||
academicTermId: terms.value.find((x) => x.isCurrent)?.id,
|
||||
academicTermId: defaultAcademicTermId(terms.value),
|
||||
name: '', notes: '',
|
||||
})
|
||||
planDialog.value = true
|
||||
@@ -339,7 +342,7 @@ onMounted(async () => {
|
||||
|
||||
<template v-if="isManager">
|
||||
<section class="exam-plan-strip">
|
||||
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id }" @click="selectPlan(plan.id)">
|
||||
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
|
||||
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
|
||||
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
|
||||
</button>
|
||||
@@ -478,7 +481,7 @@ onMounted(async () => {
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="学期">
|
||||
<el-select v-model="planForm.academicTermId">
|
||||
<el-option v-for="x in terms" :key="x.id" :label="x.name" :value="x.id" />
|
||||
<el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { CopyDocument, EditPen, Plus, Promotion, Refresh, Search, Setting } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const plans = ref<any[]>([])
|
||||
const selected = ref<any | null>(null)
|
||||
@@ -749,7 +750,7 @@ onMounted(async () => {
|
||||
classrooms.value = classroomRes.data.filter((item: any) => item.isEnabled)
|
||||
campuses.value = campusRes.data.filter((item: any) => item.isEnabled)
|
||||
buildings.value = buildingRes.data.filter((item: any) => item.isEnabled)
|
||||
termId.value = terms.value.find((item) => item.isCurrent)?.id
|
||||
termId.value = defaultAcademicTermId(terms.value)
|
||||
await Promise.all([loadPlans(false), loadSchedulingSettings()])
|
||||
})
|
||||
|
||||
@@ -775,7 +776,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section class="schedule-toolbar">
|
||||
<el-select v-model="termId" placeholder="选择学期" @change="changeTerm">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||
</el-select>
|
||||
<div class="schedule-version-strip">
|
||||
<button
|
||||
@@ -946,7 +947,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-dialog v-model="planDialog" :title="editingPlanId ? '编辑排课版本' : '新建排课版本'" width="560px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="学期" required><el-select v-model="planForm.academicTermId"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
|
||||
<el-form-item label="版本名称" required><el-input v-model="planForm.name" placeholder="如:第一轮正式课表" /></el-form-item>
|
||||
<el-form-item label="版本号" required><el-input v-model="planForm.version" placeholder="如 V1" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="planForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
import http from '../api/http'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
echarts.use([BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, CanvasRenderer])
|
||||
|
||||
@@ -367,10 +368,7 @@ onMounted(async () => {
|
||||
courseCategories.value = catRes.data
|
||||
classroomBuildings.value = bRes.data
|
||||
classroomCampuses.value = campRes.data
|
||||
if (terms.value.length > 0) {
|
||||
const cur = terms.value.find((t: any) => t.isCurrent) ?? terms.value[terms.value.length - 1]
|
||||
globalTermId.value = cur.id
|
||||
}
|
||||
globalTermId.value = defaultAcademicTermId(terms.value) ?? ''
|
||||
loadStudentStats()
|
||||
})
|
||||
</script>
|
||||
@@ -385,7 +383,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-select v-model="globalTermId" placeholder="选择学期" style="width:220px" clearable @change="() => { loadStudentStats(); loadGradeStats(); loadPassRateStats(); loadWorkloadStats(); loadClassroomStats() }">
|
||||
<el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" />
|
||||
<el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const records = ref<any[]>([])
|
||||
const openActivities = ref<any[]>([])
|
||||
const scannedActivity = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const scanLoading = ref(false)
|
||||
const checkInTargetId = ref('')
|
||||
const now = ref(Date.now())
|
||||
const appealDialog = ref(false)
|
||||
const appealTarget = ref<any>(null)
|
||||
const appealReason = ref('')
|
||||
@@ -18,14 +26,165 @@ const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | u
|
||||
const appealStatusLabels: Record<string, string> = {
|
||||
None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
|
||||
}
|
||||
const courseGroups = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
records.value.forEach((record: any) => {
|
||||
const key = record.teachingTaskId ?? `${record.courseCode}-${record.taskNumber}`
|
||||
let group = groups.get(key)
|
||||
if (!group) {
|
||||
group = {
|
||||
key,
|
||||
courseCode: record.courseCode,
|
||||
courseName: record.courseName,
|
||||
taskNumber: record.taskNumber,
|
||||
teacherNames: [...record.teacherNames],
|
||||
records: [],
|
||||
presentCount: 0,
|
||||
absentCount: 0,
|
||||
lateCount: 0,
|
||||
leaveCount: 0,
|
||||
excusedCount: 0,
|
||||
requiredCount: 0,
|
||||
attendedCount: 0,
|
||||
attendanceRate: null,
|
||||
latestAt: 0,
|
||||
}
|
||||
groups.set(key, group)
|
||||
}
|
||||
group.records.push(record)
|
||||
group.latestAt = Math.max(group.latestAt, new Date(record.attendanceDate).getTime())
|
||||
const countKey = `${record.status.charAt(0).toLowerCase()}${record.status.slice(1)}Count`
|
||||
if (countKey in group) group[countKey] += 1
|
||||
if (record.status !== 'Excused') group.requiredCount += 1
|
||||
if (record.status === 'Present' || record.status === 'Late') group.attendedCount += 1
|
||||
})
|
||||
return [...groups.values()]
|
||||
.map(group => ({
|
||||
...group,
|
||||
attendanceRate: group.requiredCount > 0
|
||||
? Math.round(group.attendedCount * 1000 / group.requiredCount) / 10
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => b.latestAt - a.latestAt)
|
||||
})
|
||||
let clockTimer: number | undefined
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { records.value = (await http.get('/attendance/my-records')).data }
|
||||
try {
|
||||
const [recordResponse, activityResponse] = await Promise.all([
|
||||
http.get('/attendance/my-records'),
|
||||
http.get('/attendance/open-check-ins'),
|
||||
])
|
||||
records.value = recordResponse.data
|
||||
openActivities.value = activityResponse.data
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
if (token) await loadScannedActivity(token)
|
||||
else scannedActivity.value = null
|
||||
}
|
||||
catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadScannedActivity(token: string) {
|
||||
scanLoading.value = true
|
||||
try {
|
||||
scannedActivity.value = (
|
||||
await http.get('/attendance/check-in-info', { params: { token } })
|
||||
).data
|
||||
} catch (error: any) {
|
||||
scannedActivity.value = null
|
||||
if (error?.response?.status === 404) {
|
||||
ElMessage.error('签到码无效,或你不在本次课程名单中。')
|
||||
} else {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
} finally {
|
||||
scanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function serverUtcTime(value: string | null | undefined) {
|
||||
if (!value) return Number.NaN
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function formatServerTime(value: string) {
|
||||
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function remainingLabel(activity: any) {
|
||||
if (!activity?.checkInEndsAt) return ''
|
||||
const remaining = serverUtcTime(activity.checkInEndsAt) - now.value
|
||||
if (remaining <= 0) return '签到已结束'
|
||||
const minutes = Math.floor(remaining / 60000)
|
||||
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
||||
}
|
||||
|
||||
async function confirmQrCheckIn() {
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
if (!token || !scannedActivity.value) return
|
||||
checkInTargetId.value = scannedActivity.value.sheetId
|
||||
try {
|
||||
const { data } = await http.post('/attendance/check-in', { token })
|
||||
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
|
||||
await router.replace({ path: route.path, query: {} })
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
checkInTargetId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function captureLocation() {
|
||||
if (!navigator.geolocation) throw new Error('unsupported')
|
||||
return await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12000,
|
||||
maximumAge: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function locationCheckIn(activity: any) {
|
||||
checkInTargetId.value = activity.sheetId
|
||||
try {
|
||||
const position = await captureLocation()
|
||||
const { data } = await http.post('/attendance/check-in', {
|
||||
attendanceSheetId: activity.sheetId,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracyMeters: position.coords.accuracy,
|
||||
})
|
||||
ElMessage.success(data.alreadyCheckedIn
|
||||
? '你已完成本次签到'
|
||||
: `签到成功${data.checkInDistanceMeters === null
|
||||
? ''
|
||||
: `,距签到点约 ${Math.round(data.checkInDistanceMeters)} 米`}`)
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (!error?.response) {
|
||||
const message = error?.code === 1
|
||||
? '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
|
||||
: error?.code === 3
|
||||
? '获取位置超时,请移到信号较好的位置后重试。'
|
||||
: '无法获取当前位置,请检查手机定位服务后重试。'
|
||||
ElMessage.error(message)
|
||||
} else {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
} finally {
|
||||
checkInTargetId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openAppeal(record: any) {
|
||||
appealTarget.value = record
|
||||
appealReason.value = ''
|
||||
@@ -49,7 +208,32 @@ function canAppeal(record: any) {
|
||||
return record.appealStatus === 'None' || record.appealStatus === 'Rejected'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? '暂无' : `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function rateTone(value: number | null) {
|
||||
if (value === null) return 'neutral'
|
||||
if (value < 80) return 'danger'
|
||||
if (value < 90) return 'warning'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
function recordDate(value: string) {
|
||||
const date = new Date(value)
|
||||
return {
|
||||
month: `${date.getMonth() + 1}月`,
|
||||
day: String(date.getDate()).padStart(2, '0'),
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
load()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,39 +242,149 @@ onMounted(load)
|
||||
<div>
|
||||
<span class="section-kicker">ATTENDANCE RECORD</span>
|
||||
<h2>我的考勤</h2>
|
||||
<p>查看所有已提交的考勤记录。对记录有异议可以提交申诉,辅导员将进行审核。</p>
|
||||
<p>完成课堂扫码或定位签到,并查看已提交的考勤记录。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="att-list">
|
||||
<article v-for="r in records" :key="`${r.attendanceSheetId}`" class="att-card">
|
||||
<div class="att-info">
|
||||
<span>{{ r.courseCode }} · {{ r.taskNumber }}</span>
|
||||
<h3>{{ r.courseName }}</h3>
|
||||
<p>{{ r.sheetName }} · {{ new Date(r.attendanceDate).toLocaleDateString('zh-CN') }} · {{ r.teacherNames.join('、') }}</p>
|
||||
<section
|
||||
v-if="route.query.token || openActivities.length"
|
||||
v-loading="loading || scanLoading"
|
||||
class="check-in-board"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span>LIVE CHECK-IN</span>
|
||||
<strong>待签到</strong>
|
||||
</div>
|
||||
<div class="att-status">
|
||||
<el-tag :type="statusColors[r.status]" size="small">{{ statusLabels[r.status] }}</el-tag>
|
||||
<span v-if="r.notes" class="att-note">{{ r.notes }}</span>
|
||||
<small>签到由服务器校验课程名单、有效时间和位置范围</small>
|
||||
</header>
|
||||
|
||||
<article v-if="scannedActivity" class="scan-ticket">
|
||||
<div class="ticket-mark"><el-icon><Check /></el-icon></div>
|
||||
<div class="ticket-course">
|
||||
<span>{{ scannedActivity.courseCode }} · {{ scannedActivity.taskNumber }}</span>
|
||||
<strong>{{ scannedActivity.courseName }}</strong>
|
||||
<p>{{ scannedActivity.sheetName }} · 扫码签到</p>
|
||||
</div>
|
||||
<div class="att-appeal">
|
||||
<div class="ticket-time">
|
||||
<Clock />
|
||||
<b>{{ remainingLabel(scannedActivity) }}</b>
|
||||
<small v-if="scannedActivity.checkInAt">
|
||||
已于 {{ formatServerTime(scannedActivity.checkInAt) }} 签到
|
||||
</small>
|
||||
<small v-else>请确认课程信息后完成签到</small>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!scannedActivity.isOpen || Boolean(scannedActivity.checkInAt)"
|
||||
:loading="checkInTargetId === scannedActivity.sheetId"
|
||||
@click="confirmQrCheckIn"
|
||||
>{{ scannedActivity.checkInAt ? '已签到' : scannedActivity.isOpen ? '确认签到' : '签到已结束' }}</el-button>
|
||||
</article>
|
||||
|
||||
<div v-if="openActivities.length" class="location-list">
|
||||
<article v-for="activity in openActivities" :key="activity.sheetId">
|
||||
<div class="location-pin"><Location /></div>
|
||||
<div>
|
||||
<span>{{ activity.courseCode }} · {{ activity.taskNumber }}</span>
|
||||
<strong>{{ activity.courseName }}</strong>
|
||||
<p>{{ activity.sheetName }} · {{ activity.locationRadiusMeters }} 米范围内</p>
|
||||
</div>
|
||||
<div class="location-clock">
|
||||
<b>{{ remainingLabel(activity) }}</b>
|
||||
<small v-if="activity.checkInAt">
|
||||
已于 {{ formatServerTime(activity.checkInAt) }} 签到
|
||||
</small>
|
||||
<small v-else>将获取一次当前位置用于本次签到</small>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="Boolean(activity.checkInAt)"
|
||||
:loading="checkInTargetId === activity.sheetId"
|
||||
@click="locationCheckIn(activity)"
|
||||
>{{ activity.checkInAt ? '已签到' : '定位并签到' }}</el-button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="record-head">
|
||||
<div>
|
||||
<strong>课程考勤档案</strong>
|
||||
<span>按课程汇总出勤率,仅统计教师已经提交的考勤结果</span>
|
||||
</div>
|
||||
<span>{{ courseGroups.length }} 门课程 · {{ records.length }} 次点名</span>
|
||||
</section>
|
||||
|
||||
<section v-loading="loading" class="course-archive">
|
||||
<article v-for="course in courseGroups" :key="course.key" class="course-record">
|
||||
<header>
|
||||
<div class="course-identity">
|
||||
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
|
||||
<h3>{{ course.courseName }}</h3>
|
||||
<small>{{ course.teacherNames.join('、') || '任课教师未登记' }}</small>
|
||||
</div>
|
||||
<div class="course-rate" :class="rateTone(course.attendanceRate)">
|
||||
<span>课程出勤率</span>
|
||||
<strong>{{ formatRate(course.attendanceRate) }}</strong>
|
||||
<small>{{ course.attendedCount }} / {{ course.requiredCount }} 次到课</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="course-summary">
|
||||
<div><b>{{ course.records.length }}</b><span>点名次数</span></div>
|
||||
<div class="present"><b>{{ course.presentCount }}</b><span>出勤</span></div>
|
||||
<div class="late"><b>{{ course.lateCount }}</b><span>迟到</span></div>
|
||||
<div class="absent"><b>{{ course.absentCount }}</b><span>缺勤</span></div>
|
||||
<div><b>{{ course.leaveCount }}</b><span>请假</span></div>
|
||||
<div><b>{{ course.excusedCount }}</b><span>免修</span></div>
|
||||
</div>
|
||||
|
||||
<div class="session-list">
|
||||
<div
|
||||
v-for="r in course.records"
|
||||
:key="r.attendanceSheetId"
|
||||
class="session-row"
|
||||
>
|
||||
<div class="session-date" aria-hidden="true">
|
||||
<span>{{ recordDate(r.attendanceDate).month }}</span>
|
||||
<strong>{{ recordDate(r.attendanceDate).day }}</strong>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<strong>{{ r.sheetName }}</strong>
|
||||
<span v-if="r.notes">{{ r.notes }}</span>
|
||||
<span v-else>本次点名没有备注</span>
|
||||
</div>
|
||||
<div class="session-result">
|
||||
<el-tag :type="statusColors[r.status]" size="small">
|
||||
{{ statusLabels[r.status] }}
|
||||
</el-tag>
|
||||
<template v-if="r.appealStatus !== 'None'">
|
||||
<el-tag size="small" :type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ appealStatusLabels[r.appealStatus] }}
|
||||
</el-tag>
|
||||
<span v-if="r.appealReviewComment" class="att-note">{{ r.appealReviewComment }}</span>
|
||||
<span v-if="r.appealReviewComment" class="review-comment">
|
||||
{{ r.appealReviewComment }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
:icon="Warning"
|
||||
@click="openAppeal(r)"
|
||||
>申诉</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!records.length" description="暂无考勤记录" />
|
||||
<el-empty v-if="!courseGroups.length" description="还没有已提交的课程考勤记录" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="appealDialog" title="考勤申诉" width="500px">
|
||||
@@ -108,12 +402,261 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.att-list { display: grid; gap: 10px; }
|
||||
.att-card { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; flex-wrap: wrap; }
|
||||
.att-info span { font-size: 11px; color: var(--muted); }
|
||||
.att-info h3 { font-size: 14px; margin: 2px 0; }
|
||||
.att-info p { font-size: 12px; color: var(--muted); margin: 0; }
|
||||
.att-status { display: flex; align-items: center; gap: 8px; }
|
||||
.att-appeal { display: flex; align-items: center; gap: 8px; }
|
||||
.att-note { font-size: 11px; color: var(--muted); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.check-in-board {
|
||||
border: 1px solid #cdd9e5;
|
||||
background: #f6f9fc;
|
||||
}
|
||||
.check-in-board > header {
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
color: white;
|
||||
background: #17395e;
|
||||
}
|
||||
.check-in-board > header > div { display: grid; }
|
||||
.check-in-board > header span {
|
||||
color: #6de0c0;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .14em;
|
||||
}
|
||||
.check-in-board > header strong { font-size: 17px; }
|
||||
.check-in-board > header small { font-size: 10px; opacity: .72; }
|
||||
.scan-ticket {
|
||||
margin: 14px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(180px, 1fr) minmax(150px, .7fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
border-left: 4px solid #2d8975;
|
||||
background: white;
|
||||
box-shadow: 0 4px 14px rgba(23, 43, 77, .07);
|
||||
}
|
||||
.ticket-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #247261;
|
||||
font-size: 23px;
|
||||
background: #e8f5ef;
|
||||
}
|
||||
.ticket-course,
|
||||
.ticket-time { display: grid; gap: 2px; }
|
||||
.ticket-course span,
|
||||
.location-list article > div:nth-child(2) > span {
|
||||
color: #176b87;
|
||||
font: 700 10px/1.2 Consolas, monospace;
|
||||
}
|
||||
.ticket-course strong,
|
||||
.location-list article strong { color: #172b4d; font-size: 15px; }
|
||||
.ticket-course p,
|
||||
.location-list article p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
|
||||
.ticket-time svg { width: 14px; color: #176b87; }
|
||||
.ticket-time b { color: #344054; font-size: 12px; }
|
||||
.ticket-time small { grid-column: 2; color: var(--muted); font-size: 9px; }
|
||||
.location-list { border-top: 1px solid #dce4ed; }
|
||||
.location-list article {
|
||||
padding: 13px 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(180px, 1fr) minmax(160px, .7fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #e3e9ef;
|
||||
background: white;
|
||||
}
|
||||
.location-list article:last-child { border-bottom: none; }
|
||||
.location-pin {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #176b87;
|
||||
background: #e9f3f5;
|
||||
}
|
||||
.location-pin svg { width: 19px; }
|
||||
.location-list article > div:nth-child(2) { display: grid; gap: 2px; }
|
||||
.location-clock { display: grid; gap: 2px; }
|
||||
.location-clock b { color: #247261; font-size: 11px; }
|
||||
.location-clock small { color: var(--muted); font-size: 9px; }
|
||||
.record-head {
|
||||
padding: 11px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--line);
|
||||
background: #fbfcfd;
|
||||
}
|
||||
.record-head > div { display: grid; gap: 1px; }
|
||||
.record-head strong { color: #172b4d; font-size: 14px; }
|
||||
.record-head span { color: var(--muted); font-size: 10px; }
|
||||
.course-archive { display: grid; gap: 14px; }
|
||||
.course-record {
|
||||
overflow: hidden;
|
||||
border: 1px solid #d8e0e9;
|
||||
background: white;
|
||||
}
|
||||
.course-record > header {
|
||||
min-height: 104px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 176px;
|
||||
border-bottom: 1px solid #dce4ed;
|
||||
}
|
||||
.course-identity {
|
||||
padding: 18px 20px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.course-identity > span {
|
||||
color: #176b87;
|
||||
font: 700 10px/1.2 Consolas, monospace;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
.course-identity h3 {
|
||||
margin: 2px 0;
|
||||
color: #172b4d;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.course-identity small { color: var(--muted); font-size: 10px; }
|
||||
.course-rate {
|
||||
padding: 14px 18px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
color: white;
|
||||
background: #17395e;
|
||||
}
|
||||
.course-rate > span {
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
opacity: .75;
|
||||
}
|
||||
.course-rate strong {
|
||||
margin: 3px 0;
|
||||
font: 700 30px/1 "Arial Narrow", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.course-rate small { font-size: 9px; opacity: .72; }
|
||||
.course-rate.good { background: #245f57; }
|
||||
.course-rate.warning { background: #8b5a18; }
|
||||
.course-rate.danger { background: #8d403d; }
|
||||
.course-rate.neutral { background: #526276; }
|
||||
.course-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
border-bottom: 1px solid #e3e9ef;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.course-summary > div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border-right: 1px solid #e3e9ef;
|
||||
}
|
||||
.course-summary > div:last-child { border-right: none; }
|
||||
.course-summary b {
|
||||
color: #344054;
|
||||
font: 700 18px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.course-summary span { color: var(--muted); font-size: 9px; }
|
||||
.course-summary .present b { color: #247261; }
|
||||
.course-summary .late b { color: #a66716; }
|
||||
.course-summary .absent b { color: #a9433e; }
|
||||
.session-list { display: grid; }
|
||||
.session-row {
|
||||
min-height: 68px;
|
||||
padding: 9px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(160px, 1fr) minmax(120px, auto) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #edf0f4;
|
||||
}
|
||||
.session-row:last-child { border-bottom: none; }
|
||||
.session-date {
|
||||
width: 42px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
color: #17395e;
|
||||
border: 1px solid #cbd7e3;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
.session-date span { font-size: 8px; font-weight: 700; }
|
||||
.session-date strong {
|
||||
font: 700 19px/1 "Arial Narrow", sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.session-info { min-width: 0; display: grid; gap: 3px; }
|
||||
.session-info strong {
|
||||
overflow: hidden;
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-info span,
|
||||
.review-comment {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-result {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.review-comment { max-width: 150px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.check-in-board > header { align-items: flex-start; flex-direction: column; }
|
||||
.scan-ticket,
|
||||
.location-list article {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
}
|
||||
.ticket-time,
|
||||
.location-clock,
|
||||
.scan-ticket > .el-button,
|
||||
.location-list article > .el-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.scan-ticket > .el-button,
|
||||
.location-list article > .el-button { width: 100%; }
|
||||
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
|
||||
.record-head { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.record-head > span { text-align: left; }
|
||||
.course-record > header { grid-template-columns: minmax(0, 1fr) 118px; }
|
||||
.course-identity { padding: 15px 13px; }
|
||||
.course-identity h3 { font-size: 17px; }
|
||||
.course-rate { padding: 12px; }
|
||||
.course-rate strong { font-size: 22px; }
|
||||
.course-summary { grid-template-columns: repeat(3, 1fr); }
|
||||
.course-summary > div:nth-child(3) { border-right: none; }
|
||||
.course-summary > div:nth-child(-n + 3) { border-bottom: 1px solid #e3e9ef; }
|
||||
.session-row {
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
gap: 9px;
|
||||
}
|
||||
.session-result {
|
||||
grid-column: 2 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.session-row > .el-button {
|
||||
grid-column: 2 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Check, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
CopyDocument,
|
||||
Download,
|
||||
Grid,
|
||||
Location,
|
||||
Plus,
|
||||
Refresh,
|
||||
Search,
|
||||
Upload,
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart, PieChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
echarts.use([
|
||||
LineChart,
|
||||
@@ -28,6 +41,12 @@ const selectedSheet = ref<any>(null)
|
||||
const sheetDetail = ref<any>(null)
|
||||
const statistics = ref<any>(null)
|
||||
const createDialog = ref(false)
|
||||
const qrDialog = ref(false)
|
||||
const createSubmitting = ref(false)
|
||||
const teacherLocationLoading = ref(false)
|
||||
const qrDataUrl = ref('')
|
||||
const qrCheckInUrl = ref('')
|
||||
const now = ref(Date.now())
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const termId = ref<string>()
|
||||
const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
|
||||
@@ -36,8 +55,18 @@ const classFilter = ref('')
|
||||
const attentionFilter = ref('')
|
||||
const statusChartEl = ref<HTMLElement>()
|
||||
const trendChartEl = ref<HTMLElement>()
|
||||
const createForm = ref({ name: '', attendanceDate: '' })
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
attendanceDate: '',
|
||||
checkInMethod: 'Manual',
|
||||
checkInDurationMinutes: 15,
|
||||
targetLatitude: null as number | null,
|
||||
targetLongitude: null as number | null,
|
||||
locationRadiusMeters: 100,
|
||||
locationAccuracyMeters: null as number | null,
|
||||
})
|
||||
const chartInstances: echarts.ECharts[] = []
|
||||
let clockTimer: number | undefined
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'Present', label: '出勤' },
|
||||
@@ -148,26 +177,181 @@ function openCreate() {
|
||||
createForm.value = {
|
||||
name: '',
|
||||
attendanceDate: new Date().toISOString().slice(0, 10),
|
||||
checkInMethod: 'Manual',
|
||||
checkInDurationMinutes: 15,
|
||||
targetLatitude: null,
|
||||
targetLongitude: null,
|
||||
locationRadiusMeters: 100,
|
||||
locationAccuracyMeters: null,
|
||||
}
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function captureTeacherLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
ElMessage.error('当前浏览器不支持定位,请更换浏览器或使用扫码签到。')
|
||||
return false
|
||||
}
|
||||
teacherLocationLoading.value = true
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12000,
|
||||
maximumAge: 0,
|
||||
})
|
||||
})
|
||||
createForm.value.targetLatitude = Number(position.coords.latitude.toFixed(7))
|
||||
createForm.value.targetLongitude = Number(position.coords.longitude.toFixed(7))
|
||||
createForm.value.locationAccuracyMeters = Math.round(position.coords.accuracy)
|
||||
ElMessage.success('已获取当前签到点')
|
||||
return true
|
||||
} catch (error: any) {
|
||||
const message = error?.code === 1
|
||||
? '定位权限被拒绝,请在浏览器地址栏中允许本网站使用位置信息。'
|
||||
: error?.code === 3
|
||||
? '获取位置超时,请移到信号较好的位置后重试。'
|
||||
: '暂时无法获取位置,请检查系统定位服务。'
|
||||
ElMessage.error(message)
|
||||
return false
|
||||
} finally {
|
||||
teacherLocationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSheet() {
|
||||
if (!createForm.value.name.trim()) {
|
||||
ElMessage.warning('请填写考勤表名称。')
|
||||
return
|
||||
}
|
||||
if (createForm.value.checkInMethod === 'Location' &&
|
||||
(createForm.value.targetLatitude === null ||
|
||||
createForm.value.targetLongitude === null) &&
|
||||
!await captureTeacherLocation()) return
|
||||
createSubmitting.value = true
|
||||
try {
|
||||
await http.post('/attendance/sheets', {
|
||||
const { data } = await http.post('/attendance/sheets', {
|
||||
teachingTaskId: selectedTask.value.id,
|
||||
name: createForm.value.name,
|
||||
attendanceDate: new Date(createForm.value.attendanceDate).toISOString(),
|
||||
checkInMethod: createForm.value.checkInMethod,
|
||||
checkInDurationMinutes: createForm.value.checkInMethod === 'Manual'
|
||||
? null
|
||||
: createForm.value.checkInDurationMinutes,
|
||||
targetLatitude: createForm.value.checkInMethod === 'Location'
|
||||
? createForm.value.targetLatitude
|
||||
: null,
|
||||
targetLongitude: createForm.value.checkInMethod === 'Location'
|
||||
? createForm.value.targetLongitude
|
||||
: null,
|
||||
locationRadiusMeters: createForm.value.checkInMethod === 'Location'
|
||||
? createForm.value.locationRadiusMeters
|
||||
: null,
|
||||
})
|
||||
createDialog.value = false
|
||||
ElMessage.success('考勤表已建立')
|
||||
ElMessage.success(createForm.value.checkInMethod === 'Manual'
|
||||
? '考勤表已建立'
|
||||
: '签到活动已发起')
|
||||
await selectTask(selectedTask.value)
|
||||
const createdSheet = sheets.value.find((sheet: any) => sheet.id === data.id)
|
||||
if (createdSheet) {
|
||||
await selectSheet(createdSheet)
|
||||
if (data.checkInMethod === 'QrCode') await showQrCode()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
createSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isOnlineMethod(method: string | number) {
|
||||
return method === 'QrCode' || method === 'Location' || method === 2 || method === 3
|
||||
}
|
||||
|
||||
function methodLabel(method: string | number) {
|
||||
if (method === 'QrCode' || method === 2) return '扫码签到'
|
||||
if (method === 'Location' || method === 3) return '定位签到'
|
||||
return '教师点名'
|
||||
}
|
||||
|
||||
function isQrCode(method: string | number) {
|
||||
return method === 'QrCode' || method === 2
|
||||
}
|
||||
|
||||
function serverUtcTime(value: string | null | undefined) {
|
||||
if (!value) return Number.NaN
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function formatServerTime(value: string) {
|
||||
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function isCheckInOpen(sheet: any) {
|
||||
if (!sheet || !isDraft(sheet.status) || !isOnlineMethod(sheet.checkInMethod)) return false
|
||||
const start = serverUtcTime(sheet.checkInStartsAt)
|
||||
const end = serverUtcTime(sheet.checkInEndsAt)
|
||||
return start <= now.value && now.value < end
|
||||
}
|
||||
|
||||
function remainingLabel(sheet: any) {
|
||||
if (!sheet?.checkInEndsAt) return ''
|
||||
const remaining = serverUtcTime(sheet.checkInEndsAt) - now.value
|
||||
if (remaining <= 0) return '签到已结束'
|
||||
const minutes = Math.floor(remaining / 60000)
|
||||
const seconds = Math.floor((remaining % 60000) / 1000)
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
||||
}
|
||||
|
||||
async function showQrCode() {
|
||||
const sheet = sheetDetail.value?.sheet
|
||||
if (!sheet?.checkInToken) {
|
||||
ElMessage.warning('未取得签到码,请刷新考勤表后重试。')
|
||||
return
|
||||
}
|
||||
qrCheckInUrl.value =
|
||||
`${window.location.origin}/my-attendance?token=${encodeURIComponent(sheet.checkInToken)}`
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
|
||||
width: 360,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#172b4d', light: '#ffffff' },
|
||||
})
|
||||
qrDialog.value = true
|
||||
} catch {
|
||||
ElMessage.error('签到二维码生成失败,请刷新页面后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCheckInLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(qrCheckInUrl.value)
|
||||
ElMessage.success('签到链接已复制')
|
||||
} catch {
|
||||
ElMessage.error('无法自动复制,请手动选择签到链接。')
|
||||
}
|
||||
}
|
||||
|
||||
async function closeCheckIn() {
|
||||
if (!sheetDetail.value?.sheet) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提前结束后,学生将不能再扫码或定位签到,仍可由教师调整名单。',
|
||||
'提前结束签到',
|
||||
{ type: 'warning', confirmButtonText: '结束签到', cancelButtonText: '继续签到' },
|
||||
)
|
||||
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/close-check-in`)
|
||||
ElMessage.success('签到已结束')
|
||||
qrDialog.value = false
|
||||
await selectTask(selectedTask.value)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,13 +591,15 @@ watch(activeMode, async mode => {
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', resizeCharts)
|
||||
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
|
||||
termId.value = defaultAcademicTermId(terms.value)
|
||||
await loadTasks()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeCharts)
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
disposeCharts()
|
||||
})
|
||||
</script>
|
||||
@@ -431,7 +617,7 @@ onUnmounted(() => {
|
||||
|
||||
<section class="attendance-toolbar">
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||||
</el-select>
|
||||
<span>共 {{ tasks.length }} 个教学班</span>
|
||||
<el-radio-group v-model="activeMode" class="mode-switch" size="small">
|
||||
@@ -482,10 +668,20 @@ onUnmounted(() => {
|
||||
@click="selectSheet(sheet)"
|
||||
>
|
||||
<div>
|
||||
<b>{{ sheet.name }}</b>
|
||||
<b>
|
||||
{{ sheet.name }}
|
||||
<span
|
||||
v-if="isOnlineMethod(sheet.checkInMethod)"
|
||||
class="method-mark"
|
||||
>{{ methodLabel(sheet.checkInMethod) }}</span>
|
||||
</b>
|
||||
<span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span>
|
||||
</div>
|
||||
<div class="sheet-stats">
|
||||
<span
|
||||
v-if="isOnlineMethod(sheet.checkInMethod)"
|
||||
class="checked-in"
|
||||
>已签到 {{ sheet.checkedInCount }}</span>
|
||||
<span class="present">出勤 {{ sheet.presentCount }}</span>
|
||||
<span class="absent" v-if="sheet.absentCount">缺勤 {{ sheet.absentCount }}</span>
|
||||
<span class="late" v-if="sheet.lateCount">迟到 {{ sheet.lateCount }}</span>
|
||||
@@ -510,9 +706,26 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<strong>{{ sheetDetail.sheet.name }}</strong>
|
||||
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
|
||||
<small>{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}</small>
|
||||
<small>
|
||||
{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }}
|
||||
· {{ methodLabel(sheetDetail.sheet.checkInMethod) }}
|
||||
· {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit && isQrCode(sheetDetail.sheet.checkInMethod)"
|
||||
size="small"
|
||||
:icon="Grid"
|
||||
@click="showQrCode"
|
||||
>显示签到码</el-button>
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit && isCheckInOpen(sheetDetail.sheet)"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click="closeCheckIn"
|
||||
>提前结束</el-button>
|
||||
<el-button
|
||||
v-if="sheetDetail.canEdit"
|
||||
size="small"
|
||||
@@ -542,6 +755,27 @@ onUnmounted(() => {
|
||||
>提交</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
||||
class="check-in-console"
|
||||
:class="{ open: isCheckInOpen(sheetDetail.sheet) }"
|
||||
>
|
||||
<div class="check-in-signal">
|
||||
<span />
|
||||
{{ isCheckInOpen(sheetDetail.sheet) ? '签到进行中' : '签到已结束' }}
|
||||
</div>
|
||||
<strong>
|
||||
{{ sheetDetail.sheet.checkedInCount }}
|
||||
<small>/ {{ sheetDetail.sheet.records.length }} 人已自主签到</small>
|
||||
</strong>
|
||||
<p>
|
||||
<Clock />
|
||||
{{ remainingLabel(sheetDetail.sheet) }}
|
||||
<template v-if="sheetDetail.sheet.locationRadiusMeters">
|
||||
· 签到点 {{ sheetDetail.sheet.locationRadiusMeters }} 米内有效
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="sheetDetail.canEdit" class="batch-row">
|
||||
<span>批量设置:</span>
|
||||
<el-button size="small" @click="batchStatus('Present')">全部出勤</el-button>
|
||||
@@ -573,6 +807,22 @@ onUnmounted(() => {
|
||||
<span v-else>{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
||||
label="自主签到"
|
||||
min-width="138"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.checkInAt" class="record-check-in">
|
||||
<b>{{ methodLabel(row.checkedInMethod) }}</b>
|
||||
<span>{{ formatServerTime(row.checkInAt) }}</span>
|
||||
<small v-if="row.checkInDistanceMeters !== null">
|
||||
距签到点 {{ Math.round(row.checkInDistanceMeters) }} 米
|
||||
</small>
|
||||
</div>
|
||||
<span v-else class="muted-cell">尚未签到</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
@@ -713,7 +963,7 @@ onUnmounted(() => {
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createDialog" title="新建考勤表" width="500px">
|
||||
<el-dialog v-model="createDialog" title="发起课堂考勤" width="620px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="考勤名称" required>
|
||||
<el-input v-model="createForm.name" placeholder="如:第3周课堂点名" maxlength="120" />
|
||||
@@ -725,10 +975,109 @@ onUnmounted(() => {
|
||||
class="full-width"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="签到方式" required>
|
||||
<div class="method-options">
|
||||
<button
|
||||
v-for="method in [
|
||||
{ value: 'Manual', icon: Check, name: '教师点名', hint: '逐人确认,可随时修改' },
|
||||
{ value: 'QrCode', icon: Grid, name: '扫码签到', hint: '投屏二维码,学生登录后签到' },
|
||||
{ value: 'Location', icon: Location, name: '定位签到', hint: '在指定签到点范围内签到' },
|
||||
]"
|
||||
:key="method.value"
|
||||
type="button"
|
||||
:class="{ active: createForm.checkInMethod === method.value }"
|
||||
@click="createForm.checkInMethod = method.value"
|
||||
>
|
||||
<el-icon><component :is="method.icon" /></el-icon>
|
||||
<b>{{ method.name }}</b>
|
||||
<span>{{ method.hint }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div v-if="createForm.checkInMethod !== 'Manual'" class="online-settings">
|
||||
<el-form-item label="签到时长" required>
|
||||
<el-input-number
|
||||
v-model="createForm.checkInDurationMinutes"
|
||||
:min="1"
|
||||
:max="180"
|
||||
:step="5"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="field-unit">分钟</span>
|
||||
</el-form-item>
|
||||
<template v-if="createForm.checkInMethod === 'Location'">
|
||||
<el-form-item label="有效范围" required>
|
||||
<el-input-number
|
||||
v-model="createForm.locationRadiusMeters"
|
||||
:min="20"
|
||||
:max="1000"
|
||||
:step="10"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="field-unit">米</span>
|
||||
</el-form-item>
|
||||
<div class="location-anchor">
|
||||
<div>
|
||||
<Location />
|
||||
<p>
|
||||
<b>教师当前位置作为签到点</b>
|
||||
<span v-if="createForm.targetLatitude !== null">
|
||||
已定位,精度约 {{ createForm.locationAccuracyMeters }} 米
|
||||
</span>
|
||||
<span v-else>创建前需要允许浏览器获取位置</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-button
|
||||
:loading="teacherLocationLoading"
|
||||
@click="captureTeacherLocation"
|
||||
>{{ createForm.targetLatitude === null ? '获取位置' : '重新定位' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createSheet">建立考勤表</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="createSubmitting"
|
||||
@click="createSheet"
|
||||
>{{ createForm.checkInMethod === 'Manual' ? '建立考勤表' : '立即发起签到' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="qrDialog"
|
||||
class="qr-dialog"
|
||||
title="课堂扫码签到"
|
||||
width="520px"
|
||||
align-center
|
||||
>
|
||||
<div v-if="sheetDetail" class="qr-stage">
|
||||
<div class="qr-course">
|
||||
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
|
||||
<strong>{{ sheetDetail.sheet.name }}</strong>
|
||||
<small>{{ sheetDetail.sheet.courseName }}</small>
|
||||
</div>
|
||||
<img :src="qrDataUrl" alt="课堂签到二维码" />
|
||||
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
|
||||
<span />
|
||||
{{ isCheckInOpen(sheetDetail.sheet) ? remainingLabel(sheetDetail.sheet) : '本次签到已结束' }}
|
||||
</div>
|
||||
<p>学生使用手机扫码,登录教务系统后完成签到</p>
|
||||
<el-input v-model="qrCheckInUrl" readonly>
|
||||
<template #append>
|
||||
<el-button :icon="CopyDocument" @click="copyCheckInLink">复制链接</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button
|
||||
v-if="sheetDetail && isCheckInOpen(sheetDetail.sheet)"
|
||||
type="warning"
|
||||
plain
|
||||
@click="closeCheckIn"
|
||||
>提前结束签到</el-button>
|
||||
<el-button type="primary" @click="qrDialog = false">完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -833,6 +1182,16 @@ onUnmounted(() => {
|
||||
}
|
||||
.attendance-sheet-list > button b { font-size: 14px; }
|
||||
.attendance-sheet-list > button span { color: var(--muted); font-size: 11px; }
|
||||
.method-mark {
|
||||
margin-left: 5px;
|
||||
padding: 2px 5px;
|
||||
color: #176b87 !important;
|
||||
font-size: 9px !important;
|
||||
font-weight: 700;
|
||||
vertical-align: middle;
|
||||
border: 1px solid #b9d5dc;
|
||||
background: #edf7f8;
|
||||
}
|
||||
.attendance-sheet-list > button i {
|
||||
color: var(--indigo);
|
||||
font-size: 10px;
|
||||
@@ -842,6 +1201,7 @@ onUnmounted(() => {
|
||||
.sheet-stats { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.sheet-stats span { font-size: 10px !important; font-weight: 700; }
|
||||
.sheet-stats .present { color: #2d8975; }
|
||||
.sheet-stats .checked-in { color: #176b87; }
|
||||
.sheet-stats .absent { color: #b34e48; }
|
||||
.sheet-stats .late { color: #c78724; }
|
||||
.sheet-stats .leave { color: #79579a; }
|
||||
@@ -864,6 +1224,56 @@ onUnmounted(() => {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.check-in-console {
|
||||
padding: 13px 15px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #dce4ed;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.check-in-console.open {
|
||||
color: #d8f4ec;
|
||||
border-color: #21506d;
|
||||
background: #17395e;
|
||||
}
|
||||
.check-in-signal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
}
|
||||
.check-in-signal > span,
|
||||
.qr-status > span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
.check-in-console.open .check-in-signal > span,
|
||||
.qr-status:not(.ended) > span {
|
||||
background: #4fe0b1;
|
||||
box-shadow: 0 0 0 4px rgba(79, 224, 177, .14);
|
||||
}
|
||||
.check-in-console > strong {
|
||||
color: #334155;
|
||||
font: 700 30px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.check-in-console.open > strong { color: white; }
|
||||
.check-in-console > strong small {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.check-in-console > p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.check-in-console > p svg { width: 12px; }
|
||||
.batch-row {
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
@@ -875,8 +1285,119 @@ onUnmounted(() => {
|
||||
.batch-row > span { font-size: 11px; color: var(--muted); }
|
||||
.batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); }
|
||||
.student-flag { margin-left: 4px; }
|
||||
.record-check-in { display: grid; gap: 1px; line-height: 1.25; }
|
||||
.record-check-in b { color: #176b87; font-size: 10px; }
|
||||
.record-check-in span,
|
||||
.record-check-in small,
|
||||
.muted-cell { color: var(--muted); font-size: 10px; }
|
||||
.full-width { width: 100%; }
|
||||
|
||||
.method-options {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.method-options > button {
|
||||
min-width: 0;
|
||||
padding: 13px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
gap: 2px 7px;
|
||||
color: #344054;
|
||||
text-align: left;
|
||||
border: 1px solid #d8dee7;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.method-options > button:hover { border-color: #87aeb9; }
|
||||
.method-options > button.active {
|
||||
color: #17395e;
|
||||
border-color: #176b87;
|
||||
box-shadow: inset 0 -3px #176b87;
|
||||
background: #f1f8f9;
|
||||
}
|
||||
.method-options .el-icon {
|
||||
grid-row: 1 / 3;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #176b87;
|
||||
font-size: 19px;
|
||||
background: #e7f2f4;
|
||||
}
|
||||
.method-options b { font-size: 12px; }
|
||||
.method-options span {
|
||||
overflow: hidden;
|
||||
color: #7b8794;
|
||||
font-size: 9px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.online-settings {
|
||||
padding: 13px 15px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0 16px;
|
||||
border: 1px solid #dce4ed;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
.online-settings :deep(.el-form-item) { margin-bottom: 10px; }
|
||||
.field-unit { margin-left: 8px; color: var(--muted); font-size: 11px; }
|
||||
.location-anchor {
|
||||
grid-column: 1 / -1;
|
||||
padding-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border-top: 1px solid #dce4ed;
|
||||
}
|
||||
.location-anchor > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.location-anchor svg {
|
||||
width: 22px;
|
||||
color: #176b87;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.location-anchor p { margin: 0; display: grid; }
|
||||
.location-anchor b { color: #344054; font-size: 11px; }
|
||||
.location-anchor span { color: var(--muted); font-size: 9px; }
|
||||
|
||||
.qr-stage {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.qr-course { display: grid; gap: 2px; }
|
||||
.qr-course span {
|
||||
color: #176b87;
|
||||
font: 700 10px/1.2 Consolas, monospace;
|
||||
}
|
||||
.qr-course strong { color: #172b4d; font-size: 20px; }
|
||||
.qr-course small { color: var(--muted); }
|
||||
.qr-stage > img {
|
||||
width: min(340px, 78vw);
|
||||
aspect-ratio: 1;
|
||||
border: 10px solid white;
|
||||
box-shadow: 0 0 0 1px #d8dee7, 0 12px 32px rgba(23, 43, 77, .12);
|
||||
}
|
||||
.qr-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #247261;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.qr-status.ended { color: #64748b; }
|
||||
.qr-stage > p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||
.qr-stage :deep(.el-input) { width: 100%; }
|
||||
|
||||
.attendance-statistics {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1094,6 +1615,10 @@ onUnmounted(() => {
|
||||
.student-statistics { margin: 0 12px 12px; }
|
||||
.student-filters .el-input,
|
||||
.student-filters .el-select { width: 100%; }
|
||||
.method-options { grid-template-columns: 1fr; }
|
||||
.method-options > button { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.online-settings { grid-template-columns: 1fr; }
|
||||
.location-anchor { align-items: flex-start; }
|
||||
.batch-row { align-items: flex-start; flex-wrap: wrap; }
|
||||
.batch-row > small { width: 100%; margin-left: 0; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { Download, Refresh } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
@@ -59,7 +60,7 @@ async function exportRoster() {
|
||||
|
||||
onMounted(async () => {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
|
||||
termId.value = defaultAcademicTermId(terms.value)
|
||||
await loadOfferings()
|
||||
})
|
||||
</script>
|
||||
@@ -77,7 +78,7 @@ onMounted(async () => {
|
||||
|
||||
<section class="roster-toolbar">
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadOfferings">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
|
||||
</el-select>
|
||||
<span>共 {{ offerings.length }} 个教学班</span>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isReviewer = computed(() =>
|
||||
@@ -56,7 +57,7 @@ async function load() {
|
||||
function openSubmit() {
|
||||
Object.assign(form, {
|
||||
academicTermId: filters.academicTermId ??
|
||||
terms.value.find((item) => item.isCurrent)?.id,
|
||||
defaultAcademicTermId(terms.value),
|
||||
courseId: undefined,
|
||||
statement: '',
|
||||
})
|
||||
@@ -118,7 +119,7 @@ async function review() {
|
||||
function openAssignment() {
|
||||
Object.assign(assignmentForm, {
|
||||
academicTermId: filters.academicTermId ??
|
||||
terms.value.find((item) => item.isCurrent)?.id,
|
||||
defaultAcademicTermId(terms.value),
|
||||
teacherId: undefined,
|
||||
courseId: undefined,
|
||||
comment: '',
|
||||
@@ -158,7 +159,7 @@ onMounted(async () => {
|
||||
courses.value = optionRes?.data?.courses ?? []
|
||||
teachers.value = optionRes?.data?.teachers ?? []
|
||||
}
|
||||
filters.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
||||
filters.academicTermId = defaultAcademicTermId(terms.value)
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
@@ -195,7 +196,7 @@ onMounted(async () => {
|
||||
<section class="data-card">
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filters.academicTermId" clearable placeholder="全部学期" @change="load">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
|
||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||
@@ -241,7 +242,7 @@ onMounted(async () => {
|
||||
<div class="form-grid">
|
||||
<el-form-item label="学期" required>
|
||||
<el-select v-model="form.academicTermId">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="拟授课程" required>
|
||||
@@ -286,7 +287,7 @@ onMounted(async () => {
|
||||
<el-form label-position="top" style="margin-top: 18px">
|
||||
<el-form-item label="学期" required>
|
||||
<el-select v-model="assignmentForm.academicTermId" style="width: 100%">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin') ?? false)
|
||||
@@ -283,11 +284,10 @@ function resetFilters() {
|
||||
|
||||
function resetForm(detail?: any) {
|
||||
Object.keys(form).forEach((key) => delete form[key])
|
||||
const currentTerm = terms.value.find((item) => item.isCurrent)
|
||||
Object.assign(form, {
|
||||
taskNumber: '',
|
||||
name: '',
|
||||
academicTermId: currentTerm?.id,
|
||||
academicTermId: defaultAcademicTermId(terms.value),
|
||||
courseId: undefined,
|
||||
capacity: 60,
|
||||
startWeek: 1,
|
||||
@@ -499,9 +499,8 @@ async function batchAction(
|
||||
}
|
||||
|
||||
function openGeneration() {
|
||||
const currentTerm = terms.value.find((item) => item.isCurrent)
|
||||
Object.assign(generationForm, {
|
||||
academicTermId: query.academicTermId ?? currentTerm?.id,
|
||||
academicTermId: query.academicTermId ?? defaultAcademicTermId(terms.value),
|
||||
courseId: undefined,
|
||||
classesPerTask: 3,
|
||||
startWeek: 1,
|
||||
@@ -576,7 +575,7 @@ onMounted(async () => {
|
||||
courses.value = courseRes.data
|
||||
classes.value = classRes.data
|
||||
majors.value = majorRes.data
|
||||
query.academicTermId = terms.value.find((item) => item.isCurrent)?.id
|
||||
query.academicTermId = defaultAcademicTermId(terms.value)
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
@@ -604,7 +603,7 @@ onMounted(async () => {
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="query.keyword" :prefix-icon="Search" clearable placeholder="搜索任务编号、教学班或课程" @keyup.enter="query.page = 1; load()" />
|
||||
<el-select v-model="query.academicTermId" clearable placeholder="全部学期">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||
</el-select>
|
||||
<el-select v-model="query.status" clearable placeholder="全部状态">
|
||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||
@@ -712,7 +711,7 @@ onMounted(async () => {
|
||||
<el-form-item label="教学班名称" required><el-input v-model="form.name" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="开课学期" required><el-select v-model="form.academicTermId" @change="loadManualEligibleTeachers"><el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" /></el-select></el-form-item>
|
||||
<el-form-item label="课程" required>
|
||||
<el-select v-model="form.courseId" filterable @change="form.teacherIds = []; form.primaryTeacherId = undefined; loadManualEligibleTeachers()">
|
||||
<el-option v-for="item in filteredManageableCourses" :key="item.id" :label="`${item.code} · ${item.name}(${item.totalHours} 学时)`" :value="item.id" />
|
||||
@@ -807,7 +806,7 @@ onMounted(async () => {
|
||||
<div class="form-grid">
|
||||
<el-form-item label="开课学期" required>
|
||||
<el-select v-model="generationForm.academicTermId" @change="loadEligibleTeachers">
|
||||
<el-option v-for="item in terms" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in terms" :key="item.id" :label="academicTermLabel(item)" :value="item.id" :class="academicTermOptionClass(item)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="公共课程" required>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile } from '../api/excel'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
@@ -297,9 +298,8 @@ async function loadPublicOptions() {
|
||||
colleges.value = data.colleges
|
||||
majors.value = data.majors
|
||||
classes.value = data.classes
|
||||
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
|
||||
termId.value = defaultAcademicTermId(data.terms)
|
||||
?? data.terms.find((item: any) => item.hasPublishedTimetable)?.id
|
||||
?? data.terms[0]?.id
|
||||
?? ''
|
||||
const initialClass = data.classes.find((item: any) => item.hasPublishedTimetable)
|
||||
?? data.classes[0]
|
||||
@@ -502,9 +502,7 @@ onMounted(async () => {
|
||||
if (isTeacherView.value) {
|
||||
const { data } = await http.get('/timetables/options')
|
||||
terms.value = data.terms
|
||||
termId.value = data.terms.find((item: any) => item.isCurrent)?.id
|
||||
?? data.terms[0]?.id
|
||||
?? ''
|
||||
termId.value = defaultAcademicTermId(data.terms) ?? ''
|
||||
await loadTimetable()
|
||||
return
|
||||
}
|
||||
@@ -539,8 +537,9 @@ onMounted(async () => {
|
||||
<el-option
|
||||
v-for="term in terms"
|
||||
:key="term.id"
|
||||
:label="`${term.name}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
|
||||
:label="`${academicTermLabel(term)}${!isManager && !term.hasPublishedTimetable ? '(未发布)' : ''}`"
|
||||
:value="term.id"
|
||||
:class="academicTermOptionClass(term)"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-if="isManager" v-model="planId" placeholder="选择课表版本">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isSuperAdmin = computed(() => auth.user?.roles.includes('SuperAdmin'))
|
||||
@@ -93,7 +94,7 @@ async function acknowledge(w: any) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = terms.value.find(t => t.isCurrent)?.id; await load() }
|
||||
try { terms.value = (await http.get('/base-data/terms')).data; termId.value = defaultAcademicTermId(terms.value) ?? ''; await load() }
|
||||
catch (e) { ElMessage.error(apiErrorMessage(e)) }
|
||||
})
|
||||
</script>
|
||||
@@ -103,7 +104,7 @@ onMounted(async () => {
|
||||
<section class="page-intro">
|
||||
<div><span class="section-kicker">ACADEMIC WARNING</span><h2>{{ isStudent ? '我的预警' : '学业预警' }}</h2><p>{{ isSuperAdmin ? '配置预警规则,执行检测,查看预警记录。' : isCounselor ? '查看所管学生的学业预警情况。' : '查看并确认您的学业预警通知。' }}</p></div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="t.name" :value="t.id" /></el-select>
|
||||
<el-select v-model="termId" clearable @change="load" style="width:240px"><el-option v-for="t in terms" :key="t.id" :label="academicTermLabel(t)" :value="t.id" :class="academicTermOptionClass(t)" /></el-select>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user