成绩+点名

This commit is contained in:
2026-07-25 14:08:50 +08:00 Unverified
parent 7800951aee
commit 36049af692
16 changed files with 1809 additions and 98 deletions
@@ -0,0 +1,359 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using ClosedXML.Excel;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = AttendanceRoles)]
[Route("api/attendance")]
public sealed class AttendanceController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string AttendanceRoles =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
[HttpGet("my-tasks")]
public async Task<ActionResult> GetMyTasks(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var tasks = AccessibleTasks().AsNoTracking()
.Where(x => x.Status == TeachingTaskStatus.Published);
if (academicTermId.HasValue)
tasks = tasks.Where(x => x.AcademicTermId == academicTermId);
var result = await tasks
.OrderBy(x => x.Course!.Code)
.Select(x => new
{
x.Id,
x.TaskNumber,
x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course!.Name,
StudentCount = db.CourseEnrollments.Count(enrollment =>
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId == x.Id),
SheetCount = db.AttendanceSheets.Count(sheet =>
sheet.TeachingTaskId == x.Id)
})
.ToListAsync(cancellationToken);
return Ok(result);
}
[HttpGet("sheets")]
public async Task<ActionResult> GetSheets(
Guid teachingTaskId,
CancellationToken cancellationToken)
{
var task = await AccessibleTasks().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == teachingTaskId, cancellationToken);
if (task is null) return NotFound();
var sheets = await db.AttendanceSheets.AsNoTracking()
.Where(x => x.TeachingTaskId == teachingTaskId)
.OrderByDescending(x => x.AttendanceDate)
.Select(x => new
{
x.Id,
x.Name,
x.AttendanceDate,
x.Status,
x.Notes,
x.SubmittedAt,
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),
LeaveCount = x.Records.Count(r => r.Status == AttendanceStatus.Leave),
ExcusedCount = x.Records.Count(r => r.Status == AttendanceStatus.Excused),
TotalCount = x.Records.Count
})
.ToListAsync(cancellationToken);
return Ok(sheets);
}
[HttpPost("sheets")]
public async Task<ActionResult> CreateSheet(
AttendanceSheetRequest request,
CancellationToken cancellationToken)
{
var task = await AccessibleTasks().AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
var studentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == request.TeachingTaskId)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (studentIds.Count == 0)
return ConflictProblem("该教学班没有有效选课学生。");
var sheet = new AttendanceSheet
{
TeachingTaskId = request.TeachingTaskId,
Name = request.Name.Trim(),
AttendanceDate = request.AttendanceDate,
Notes = Normalize(request.Notes),
Records = studentIds.Select(studentId => new AttendanceRecord
{
StudentId = studentId
}).ToList()
};
db.AttendanceSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
}
[HttpGet("sheets/{id:guid}")]
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.Name,
x.AttendanceDate,
x.Status,
x.Notes,
x.SubmittedAt,
TaskNumber = x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
Records = x.Records
.OrderBy(r => r.Student!.StudentNumber)
.Select(r => new
{
r.StudentId,
r.Student!.StudentNumber,
r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name,
r.Status,
r.Notes
})
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var canEdit = sheet.Status == AttendanceSheetStatus.Draft;
return Ok(new { Sheet = sheet, CanEdit = canEdit });
}
[HttpPut("sheets/{id:guid}/records")]
public async Task<ActionResult> UpdateRecords(
Guid id,
AttendanceRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交,不能修改。");
var recordMap = sheet.Records.ToDictionary(r => r.StudentId);
foreach (var item in request.Records)
{
if (recordMap.TryGetValue(item.StudentId, out var record))
{
record.Status = item.Status;
record.Notes = Normalize(item.Notes);
}
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/import")]
public async Task<ActionResult> Import(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交,不能导入。");
var rows = await ExcelWorkbookHelper.ReadAsync(
file,
new HashSet<string> { "学号", "考勤状态" },
cancellationToken);
var studentNumberMap = sheet.Records
.ToDictionary(r => r.Student!.StudentNumber, r => r);
var statusMap = new Dictionary<string, AttendanceStatus>(StringComparer.OrdinalIgnoreCase)
{
["出勤"] = AttendanceStatus.Present,
["缺勤"] = AttendanceStatus.Absent,
["迟到"] = AttendanceStatus.Late,
["请假"] = AttendanceStatus.Leave,
["免修"] = AttendanceStatus.Excused
};
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"];
var statusText = row["考勤状态"];
if (string.IsNullOrEmpty(studentNumber)) continue;
if (!studentNumberMap.TryGetValue(studentNumber, out var record)) continue;
if (statusMap.TryGetValue(statusText, out var status))
{
record.Status = status;
updated++;
}
}
await db.SaveChangesAsync(cancellationToken);
return Ok(new { Updated = updated });
}
[HttpGet("sheets/{id:guid}/export.xlsx")]
public async Task<ActionResult> Export(Guid id, CancellationToken cancellationToken)
{
var sheetData = await db.AttendanceSheets.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Name,
x.AttendanceDate,
TaskNumber = x.TeachingTask!.TaskNumber,
CourseName = x.TeachingTask.Course!.Name,
Records = x.Records
.OrderBy(r => r.Student!.StudentNumber)
.Select(r => new
{
r.Student!.StudentNumber,
r.Student.Name,
ClassName = r.Student.AdministrativeClass!.Name,
r.Status,
r.Notes
})
.ToList()
})
.FirstOrDefaultAsync(cancellationToken);
if (sheetData is null) return NotFound();
var statusLabels = new Dictionary<AttendanceStatus, string>
{
[AttendanceStatus.Present] = "出勤",
[AttendanceStatus.Absent] = "缺勤",
[AttendanceStatus.Late] = "迟到",
[AttendanceStatus.Leave] = "请假",
[AttendanceStatus.Excused] = "免修"
};
var bytes = ExcelWorkbookHelper.Create(
"考勤表",
["学号", "姓名", "班级", "考勤状态", "备注"],
sheetData.Records.Select(r => new List<object?>
{
r.StudentNumber, r.Name, r.ClassName,
statusLabels.GetValueOrDefault(r.Status, "出勤"), r.Notes
}).ToList<IReadOnlyList<object?>>());
return File(bytes, ExcelWorkbookHelper.ContentType,
$"考勤表-{sheetData.TaskNumber}-{sheetData.AttendanceDate:yyyyMMdd}.xlsx");
}
[HttpPost("sheets/{id:guid}/submit")]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
if (sheet.Status != AttendanceSheetStatus.Draft)
return ConflictProblem("考勤表已提交。");
sheet.Status = AttendanceSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("sheets/{id:guid}")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var sheet = await db.AttendanceSheets
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanManageSheet(sheet)) return Forbid();
db.AttendanceSheets.Remove(sheet);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
private IQueryable<TeachingTask> AccessibleTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private bool CanManageSheet(AttendanceSheet sheet) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
sheet.TeachingTask!.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成考勤操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
}
public sealed record AttendanceSheetRequest(
Guid TeachingTaskId,
[MaxLength(120)] string Name,
DateTime AttendanceDate,
[MaxLength(500)] string? Notes);
public sealed record AttendanceRecordsRequest(
IReadOnlyCollection<AttendanceRecordRequest> Records);
public sealed record AttendanceRecordRequest(
Guid StudentId,
AttendanceStatus Status,
[MaxLength(300)] string? Notes);
@@ -4,6 +4,7 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.CourseSelection; using Jiaowu.Api.Infrastructure.CourseSelection;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -868,6 +869,100 @@ public sealed class CourseSelectionsController(
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
[HttpGet("my-offerings")]
[Authorize(Roles = SystemRoles.Teacher)]
public async Task<ActionResult> GetMyOfferings(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var offeringsQuery = db.CourseSelectionOfferings.AsNoTracking()
.Where(x =>
x.TeachingTask!.Teachers.Any(t =>
t.Teacher!.UserId == userId) &&
x.CourseSelectionRound!.Status != CourseSelectionRoundStatus.Draft);
if (academicTermId.HasValue)
offeringsQuery = offeringsQuery.Where(x =>
x.CourseSelectionRound!.AcademicTermId == academicTermId);
var offerings = await offeringsQuery
.OrderByDescending(x => x.CourseSelectionRound!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.Course!.Code)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
RoundName = x.CourseSelectionRound!.Name,
x.Capacity,
EnrolledCount = x.Enrollments.Count(e =>
e.Status == CourseEnrollmentStatus.Enrolled),
RoundStatus = x.CourseSelectionRound.Status
})
.ToListAsync(cancellationToken);
return Ok(offerings);
}
[HttpGet("offerings/{id:guid}/roster/export.xlsx")]
[Authorize(Roles = RosterReaders)]
public async Task<ActionResult> ExportRoster(
Guid id,
CancellationToken cancellationToken)
{
var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTask!.TaskNumber,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
RoundName = x.CourseSelectionRound!.Name,
CollegeId = x.TeachingTask.Course.CollegeId,
TeacherUserIds = x.TeachingTask.Teachers
.Select(item => item.Teacher!.UserId)
})
.FirstOrDefaultAsync(cancellationToken);
if (offering is null) return NotFound();
var scope = currentUserDataScope.Current;
var isAssignedTeacher =
scope.IsInRole(SystemRoles.Teacher) &&
offering.TeacherUserIds.Contains(scope.UserId);
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
return Forbid();
var students = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new
{
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
MajorName = x.Student.AdministrativeClass.Major!.Name,
x.EnrolledAt
})
.ToListAsync(cancellationToken);
var bytes = ExcelWorkbookHelper.Create(
"选课名单",
["学号", "姓名", "班级", "专业", "选课时间"],
students.Select(s => new List<object?>
{
s.StudentNumber, s.Name, s.ClassName, s.MajorName,
s.EnrolledAt.ToString("yyyy-MM-dd HH:mm")
}).ToList<IReadOnlyList<object?>>());
return File(bytes, ExcelWorkbookHelper.ContentType,
$"选课名单-{offering.TaskNumber}.xlsx");
}
private IQueryable<CourseSelectionOffering> ScopedOfferings() private IQueryable<CourseSelectionOffering> ScopedOfferings()
{ {
var source = db.CourseSelectionOfferings.AsQueryable(); var source = db.CourseSelectionOfferings.AsQueryable();
+129 -35
View File
@@ -71,8 +71,13 @@ public sealed class GradesController(
sheet.Id, sheet.Id,
sheet.Status, sheet.Status,
sheet.RegularWeight, sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight, sheet.FinalWeight,
Items = sheet.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
StudentCount = sheet.Records.Count, StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record => CompletedCount = sheet.Records.Count(record =>
record.TotalScore != null || record.TotalScore != null ||
@@ -99,11 +104,20 @@ public sealed class GradesController(
GradeSheetRequest request, GradeSheetRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid( if (!GradeCalculator.AreWeightsValid(
request.RegularWeight, request.RegularWeight,
request.MidtermWeight, request.FinalWeight,
request.FinalWeight)) items))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。"); return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var task = await AccessibleTasks() var task = await AccessibleTasks()
.Include(x => x.Teachers) .Include(x => x.Teachers)
@@ -131,11 +145,15 @@ public sealed class GradesController(
{ {
TeachingTaskId = task.Id, TeachingTaskId = task.Id,
RegularWeight = request.RegularWeight, RegularWeight = request.RegularWeight,
MidtermWeight = request.MidtermWeight,
FinalWeight = request.FinalWeight, FinalWeight = request.FinalWeight,
Items = items,
Records = studentIds.Select(studentId => new GradeRecord Records = studentIds.Select(studentId => new GradeRecord
{ {
StudentId = studentId StudentId = studentId,
ItemScores = items.Select(item => new GradeItemScore
{
GradeItemId = item.Id
}).ToList()
}).ToList() }).ToList()
}; };
db.GradeSheets.Add(sheet); db.GradeSheets.Add(sheet);
@@ -166,8 +184,13 @@ public sealed class GradesController(
ClassNames = x.TeachingTask.Classes ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name), .Select(item => item.AdministrativeClass!.Name),
x.RegularWeight, x.RegularWeight,
x.MidtermWeight,
x.FinalWeight, x.FinalWeight,
Items = x.Items.OrderBy(item => item.SortOrder).Select(item => new
{
item.Id,
item.Name,
item.Weight
}),
x.Status, x.Status,
x.ReviewComment, x.ReviewComment,
x.SubmittedAt, x.SubmittedAt,
@@ -183,8 +206,15 @@ public sealed class GradesController(
record.Student.Name, record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name, ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore, record.RegularScore,
record.MidtermScore,
record.FinalScore, record.FinalScore,
ItemScores = record.ItemScores
.OrderBy(itemScore => itemScore.GradeItem!.SortOrder)
.Select(itemScore => new
{
itemScore.GradeItemId,
itemScore.GradeItem!.Name,
itemScore.Score
}),
record.TotalScore, record.TotalScore,
record.GradePoint, record.GradePoint,
record.ExamStatus, record.ExamStatus,
@@ -218,20 +248,58 @@ public sealed class GradesController(
GradeWeightsRequest request, GradeWeightsRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var items = request.Items?
.Select((item, index) => new GradeItem
{
Name = item.Name.Trim(),
Weight = item.Weight,
SortOrder = index
})
.ToList() ?? [];
if (!GradeCalculator.AreWeightsValid( if (!GradeCalculator.AreWeightsValid(
request.RegularWeight, request.RegularWeight,
request.MidtermWeight, request.FinalWeight,
request.FinalWeight)) items))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。"); return ValidationProblem("平时、期末与所有分项的比例必须合计 100%。");
var sheet = await EditableSheetAsync(id, cancellationToken);
var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.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 (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改成绩构成。"); return ConflictProblem("当前状态不能修改成绩构成。");
sheet.RegularWeight = request.RegularWeight; sheet.RegularWeight = request.RegularWeight;
sheet.MidtermWeight = request.MidtermWeight;
sheet.FinalWeight = request.FinalWeight; sheet.FinalWeight = request.FinalWeight;
foreach (var record in sheet.Records) Recalculate(sheet, record);
db.GradeItems.RemoveRange(sheet.Items);
var itemIdsToRemove = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var record in sheet.Records)
{
var toRemove = record.ItemScores
.Where(itemScore => itemIdsToRemove.Contains(itemScore.GradeItemId))
.ToList();
foreach (var removed in toRemove)
record.ItemScores.Remove(removed);
}
sheet.Items = items;
foreach (var record in sheet.Records)
{
foreach (var item in items)
{
record.ItemScores.Add(new GradeItemScore { GradeItemId = item.Id });
}
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
@@ -242,28 +310,49 @@ public sealed class GradesController(
GradeRecordsRequest request, GradeRecordsRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var sheet = await EditableSheetAsync(id, cancellationToken); var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.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 (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能继续修改。"); return ConflictProblem("成绩单提交后不能继续修改。");
var records = sheet.Records.ToDictionary(x => x.Id); var records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count || if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id))) request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。"); return ValidationProblem("包含无效或重复的成绩记录。");
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
foreach (var item in request.Records) foreach (var item in request.Records)
{ {
if (!ValidScore(item.RegularScore) || if (!ValidScore(item.RegularScore) ||
!ValidScore(item.MidtermScore) ||
!ValidScore(item.FinalScore)) !ValidScore(item.FinalScore))
return ValidationProblem("成绩必须在 0—100 分之间。"); return ValidationProblem("成绩必须在 0—100 分之间。");
var record = records[item.Id]; var record = records[item.Id];
record.RegularScore = item.RegularScore; record.RegularScore = item.RegularScore;
record.MidtermScore = item.MidtermScore;
record.FinalScore = item.FinalScore; record.FinalScore = item.FinalScore;
record.ExamStatus = item.ExamStatus; record.ExamStatus = item.ExamStatus;
record.Notes = Normalize(item.Notes); record.Notes = Normalize(item.Notes);
if (item.ItemScores is not null)
{
var scoreMap = record.ItemScores.ToDictionary(s => s.GradeItemId);
foreach (var scoreEntry in item.ItemScores)
{
if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
existing.Score = scoreEntry.Score;
}
}
Recalculate(sheet, record); Recalculate(sheet, record);
} }
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
@@ -273,7 +362,14 @@ public sealed class GradesController(
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{ {
var sheet = await EditableSheetAsync(id, cancellationToken); var sheet = await AccessibleSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.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 (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid(); if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned)) if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
@@ -412,16 +508,6 @@ public sealed class GradesController(
db.GradeSheets.Where(x => AccessibleTasks().Any(task => db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
task.Id == x.TeachingTaskId)); task.Id == x.TeachingTaskId));
private async Task<GradeSheet?> EditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await AccessibleSheets()
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private bool CanInitialize(TeachingTask task) => private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) || currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
@@ -449,11 +535,11 @@ public sealed class GradesController(
{ {
record.TotalScore = GradeCalculator.CalculateTotal( record.TotalScore = GradeCalculator.CalculateTotal(
record.RegularScore, record.RegularScore,
record.MidtermScore,
record.FinalScore, record.FinalScore,
record.ItemScores.ToList(),
sheet.RegularWeight, sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight, sheet.FinalWeight,
sheet.Items.ToList(),
record.ExamStatus); record.ExamStatus);
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore); record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
} }
@@ -492,13 +578,17 @@ public sealed class GradesController(
public sealed record GradeSheetRequest( public sealed record GradeSheetRequest(
Guid TeachingTaskId, Guid TeachingTaskId,
[Range(typeof(decimal), "0", "100")] decimal RegularWeight, [Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight, [Range(typeof(decimal), "0", "100")] decimal FinalWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight); IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeItemRequest(
[MaxLength(60)] string Name,
[Range(typeof(decimal), "0", "100")] decimal Weight);
public sealed record GradeWeightsRequest( public sealed record GradeWeightsRequest(
[Range(typeof(decimal), "0", "100")] decimal RegularWeight, [Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight, [Range(typeof(decimal), "0", "100")] decimal FinalWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight); IReadOnlyCollection<GradeItemRequest>? Items);
public sealed record GradeRecordsRequest( public sealed record GradeRecordsRequest(
IReadOnlyCollection<GradeRecordRequest> Records); IReadOnlyCollection<GradeRecordRequest> Records);
@@ -506,10 +596,14 @@ public sealed record GradeRecordsRequest(
public sealed record GradeRecordRequest( public sealed record GradeRecordRequest(
Guid Id, Guid Id,
decimal? RegularScore, decimal? RegularScore,
decimal? MidtermScore,
decimal? FinalScore, decimal? FinalScore,
IReadOnlyCollection<GradeItemScoreRequest>? ItemScores,
GradeExamStatus ExamStatus, GradeExamStatus ExamStatus,
[MaxLength(300)] string? Notes); [MaxLength(300)] string? Notes);
public sealed record GradeItemScoreRequest(
Guid GradeItemId,
decimal? Score);
public sealed record GradeReviewRequest( public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment); [MaxLength(500)] string? Comment);
@@ -0,0 +1,40 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class AttendanceSheet : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public required string Name { get; set; }
public DateTime AttendanceDate { get; set; }
public AttendanceSheetStatus Status { get; set; } = AttendanceSheetStatus.Draft;
public string? Notes { get; set; }
public DateTime? SubmittedAt { get; set; }
public ICollection<AttendanceRecord> Records { get; set; } = [];
}
public sealed class AttendanceRecord
{
public Guid AttendanceSheetId { get; set; }
public AttendanceSheet? AttendanceSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public AttendanceStatus Status { get; set; } = AttendanceStatus.Present;
public string? Notes { get; set; }
}
public enum AttendanceSheetStatus
{
Draft = 1,
Submitted = 2
}
public enum AttendanceStatus
{
Present = 1,
Absent = 2,
Late = 3,
Leave = 4,
Excused = 5
}
@@ -7,7 +7,6 @@ public sealed class GradeSheet : EntityBase
public Guid TeachingTaskId { get; set; } public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; } public TeachingTask? TeachingTask { get; set; }
public decimal RegularWeight { get; set; } = 30; public decimal RegularWeight { get; set; } = 30;
public decimal MidtermWeight { get; set; }
public decimal FinalWeight { get; set; } = 70; public decimal FinalWeight { get; set; } = 70;
public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft; public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft;
public string? ReviewComment { get; set; } public string? ReviewComment { get; set; }
@@ -15,6 +14,17 @@ public sealed class GradeSheet : EntityBase
public DateTime? ReviewedAt { get; set; } public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; } public DateTime? PublishedAt { get; set; }
public ICollection<GradeRecord> Records { get; set; } = []; public ICollection<GradeRecord> Records { get; set; } = [];
public ICollection<GradeItem> Items { get; set; } = [];
}
public sealed class GradeItem : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public required string Name { get; set; }
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public ICollection<GradeItemScore> Scores { get; set; } = [];
} }
public sealed class GradeRecord : EntityBase public sealed class GradeRecord : EntityBase
@@ -24,12 +34,21 @@ public sealed class GradeRecord : EntityBase
public Guid StudentId { get; set; } public Guid StudentId { get; set; }
public Student? Student { get; set; } public Student? Student { get; set; }
public decimal? RegularScore { get; set; } public decimal? RegularScore { get; set; }
public decimal? MidtermScore { get; set; }
public decimal? FinalScore { get; set; } public decimal? FinalScore { get; set; }
public decimal? TotalScore { get; set; } public decimal? TotalScore { get; set; }
public decimal? GradePoint { get; set; } public decimal? GradePoint { get; set; }
public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal; public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal;
public string? Notes { get; set; } public string? Notes { get; set; }
public ICollection<GradeItemScore> ItemScores { get; set; } = [];
}
public sealed class GradeItemScore
{
public Guid GradeRecordId { get; set; }
public GradeRecord? GradeRecord { get; set; }
public Guid GradeItemId { get; set; }
public GradeItem? GradeItem { get; set; }
public decimal? Score { get; set; }
} }
public enum GradeSheetStatus public enum GradeSheetStatus
@@ -6,34 +6,45 @@ public static class GradeCalculator
{ {
public static bool AreWeightsValid( public static bool AreWeightsValid(
decimal regularWeight, decimal regularWeight,
decimal midtermWeight, decimal finalWeight,
decimal finalWeight) => IEnumerable<GradeItem> items) =>
regularWeight is >= 0 and <= 100 && regularWeight is >= 0 and <= 100 &&
midtermWeight is >= 0 and <= 100 &&
finalWeight is >= 0 and <= 100 && finalWeight is >= 0 and <= 100 &&
regularWeight + midtermWeight + finalWeight == 100; items.All(item => item.Weight is >= 0 and <= 100) &&
regularWeight + finalWeight + items.Sum(item => item.Weight) == 100;
public static decimal? CalculateTotal( public static decimal? CalculateTotal(
decimal? regularScore, decimal? regularScore,
decimal? midtermScore,
decimal? finalScore, decimal? finalScore,
IReadOnlyCollection<GradeItemScore> itemScores,
decimal regularWeight, decimal regularWeight,
decimal midtermWeight,
decimal finalWeight, decimal finalWeight,
IReadOnlyCollection<GradeItem> items,
GradeExamStatus examStatus) GradeExamStatus examStatus)
{ {
if (examStatus != GradeExamStatus.Normal || if (examStatus != GradeExamStatus.Normal)
regularWeight > 0 && !regularScore.HasValue || return null;
midtermWeight > 0 && !midtermScore.HasValue ||
finalWeight > 0 && !finalScore.HasValue) if (regularWeight > 0 && !regularScore.HasValue)
return null;
if (finalWeight > 0 && !finalScore.HasValue)
return null;
var itemWeightById = items.ToDictionary(item => item.Id, item => item.Weight);
foreach (var itemScore in itemScores)
{ {
if (itemWeightById.TryGetValue(itemScore.GradeItemId, out var weight) &&
weight > 0 && !itemScore.Score.HasValue)
return null; return null;
} }
var total = var total = (regularScore ?? 0) * regularWeight / 100;
(regularScore ?? 0) * regularWeight / 100 + foreach (var itemScore in itemScores)
(midtermScore ?? 0) * midtermWeight / 100 + {
(finalScore ?? 0) * finalWeight / 100; if (itemWeightById.TryGetValue(itemScore.GradeItemId, out var weight))
total += (itemScore.Score ?? 0) * weight / 100;
}
total += (finalScore ?? 0) * finalWeight / 100;
return Math.Round(total, 1, MidpointRounding.AwayFromZero); return Math.Round(total, 1, MidpointRounding.AwayFromZero);
} }
@@ -47,6 +47,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>(); public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>(); public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>(); public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<GradeItem> GradeItems => Set<GradeItem>();
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>(); public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
public DbSet<ExamSession> ExamSessions => Set<ExamSession>(); public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators => public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
@@ -468,7 +472,6 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<GradeSheet>(entity => builder.Entity<GradeSheet>(entity =>
{ {
entity.Property(x => x.RegularWeight).HasPrecision(5, 1); entity.Property(x => x.RegularWeight).HasPrecision(5, 1);
entity.Property(x => x.MidtermWeight).HasPrecision(5, 1);
entity.Property(x => x.FinalWeight).HasPrecision(5, 1); entity.Property(x => x.FinalWeight).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500); entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.TeachingTaskId).IsUnique(); entity.HasIndex(x => x.TeachingTaskId).IsUnique();
@@ -479,10 +482,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<GradeItem>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items)
.HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<GradeRecord>(entity => builder.Entity<GradeRecord>(entity =>
{ {
entity.Property(x => x.RegularScore).HasPrecision(5, 1); entity.Property(x => x.RegularScore).HasPrecision(5, 1);
entity.Property(x => x.MidtermScore).HasPrecision(5, 1);
entity.Property(x => x.FinalScore).HasPrecision(5, 1); entity.Property(x => x.FinalScore).HasPrecision(5, 1);
entity.Property(x => x.TotalScore).HasPrecision(5, 1); entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.GradePoint).HasPrecision(3, 1); entity.Property(x => x.GradePoint).HasPrecision(3, 1);
@@ -499,6 +512,45 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<GradeItemScore>(entity =>
{
entity.HasKey(x => new { x.GradeRecordId, x.GradeItemId });
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.HasOne(x => x.GradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.GradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.GradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.GradeItemId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AttendanceSheet>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
entity.Property(x => x.Notes).HasMaxLength(500);
entity.HasIndex(x => new { x.TeachingTaskId, x.AttendanceDate });
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AttendanceRecord>(entity =>
{
entity.HasKey(x => new { x.AttendanceSheetId, x.StudentId });
entity.Property(x => x.Notes).HasMaxLength(300);
entity.HasOne(x => x.AttendanceSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.AttendanceSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExamPlan>(entity => builder.Entity<ExamPlan>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -546,7 +546,6 @@ public sealed class DatabaseInitializer(
{ {
TeachingTaskId = task.Id, TeachingTaskId = task.Id,
RegularWeight = 30, RegularWeight = 30,
MidtermWeight = 0,
FinalWeight = 70, FinalWeight = 70,
Status = GradeSheetStatus.Draft, Status = GradeSheetStatus.Draft,
Records = Records =
@@ -14,6 +14,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string CourseSelectionMigration = "20260724_06_course_selection"; private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades"; private const string GradesMigration = "20260724_07_grades";
private const string ExamsMigration = "20260724_08_exams"; private const string ExamsMigration = "20260724_08_exams";
private const string AttendanceMigration = "20260724_08b_attendance";
private const string StudentStatusChangesMigration = "20260724_09_student_status_changes"; private const string StudentStatusChangesMigration = "20260724_09_student_status_changes";
private const string GraduationAuditsMigration = "20260724_10_graduation_audits"; private const string GraduationAuditsMigration = "20260724_10_graduation_audits";
private const string DegreeAwardsMigration = "20260724_11_degree_awards"; private const string DegreeAwardsMigration = "20260724_11_degree_awards";
@@ -29,6 +30,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260725_17_teaching_task_scheduling_modes"; "20260725_17_teaching_task_scheduling_modes";
private const string SchedulePublishJobsMigration = private const string SchedulePublishJobsMigration =
"20260725_18_schedule_publish_jobs"; "20260725_18_schedule_publish_jobs";
private const string FlexibleGradesMigration =
"20260725_19_flexible_grades";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -177,6 +180,34 @@ public sealed class DevelopmentSqliteMigrator(
SchedulePublishJobsMigration, SchedulePublishJobsMigration,
schedulePublishJobsExist ? [] : SchedulePublishJobStatements, schedulePublishJobsExist ? [] : SchedulePublishJobStatements,
cancellationToken); cancellationToken);
var midtermWeightExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('GradeSheets')
WHERE name = 'MidtermWeight'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
FlexibleGradesMigration,
midtermWeightExists
? FlexibleGradesUpgradeStatements
: FlexibleGradesNewStatements,
cancellationToken);
var attendanceSheetsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'AttendanceSheets'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AttendanceMigration,
attendanceSheetsExist ? [] : AttendanceStatements,
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -629,7 +660,6 @@ public sealed class DevelopmentSqliteMigrator(
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY, "Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL,
"RegularWeight" TEXT NOT NULL, "RegularWeight" TEXT NOT NULL,
"MidtermWeight" TEXT NOT NULL,
"FinalWeight" TEXT NOT NULL, "FinalWeight" TEXT NOT NULL,
"Status" INTEGER NOT NULL, "Status" INTEGER NOT NULL,
"ReviewComment" TEXT NULL, "ReviewComment" TEXT NULL,
@@ -656,7 +686,6 @@ public sealed class DevelopmentSqliteMigrator(
"GradeSheetId" TEXT NOT NULL, "GradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL, "StudentId" TEXT NOT NULL,
"RegularScore" TEXT NULL, "RegularScore" TEXT NULL,
"MidtermScore" TEXT NULL,
"FinalScore" TEXT NULL, "FinalScore" TEXT NULL,
"TotalScore" TEXT NULL, "TotalScore" TEXT NULL,
"GradePoint" TEXT NULL, "GradePoint" TEXT NULL,
@@ -723,6 +752,182 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");""" """CREATE INDEX IF NOT EXISTS "IX_ExamSessionInvigilators_TeacherId" ON "ExamSessionInvigilators" ("TeacherId");"""
]; ];
private static readonly string[] FlexibleGradesNewStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GradeItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeItems" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Weight" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeItems_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeItems_GradeSheetId_SortOrder"
ON "GradeItems" ("GradeSheetId", "SortOrder");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeItemScores" (
"GradeRecordId" TEXT NOT NULL,
"GradeItemId" TEXT NOT NULL,
"Score" TEXT NULL,
CONSTRAINT "PK_GradeItemScores" PRIMARY KEY ("GradeRecordId", "GradeItemId"),
CONSTRAINT "FK_GradeItemScores_GradeRecords_GradeRecordId"
FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeItemScores_GradeItems_GradeItemId"
FOREIGN KEY ("GradeItemId") REFERENCES "GradeItems" ("Id") ON DELETE RESTRICT
);
"""
];
private static readonly string[] FlexibleGradesUpgradeStatements =
[
"""
CREATE TABLE "GradeSheets_v2" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"RegularWeight" TEXT NOT NULL,
"FinalWeight" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"ReviewComment" TEXT NULL,
"SubmittedAt" TEXT NULL,
"ReviewedAt" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeSheets_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
INSERT INTO "GradeSheets_v2" ("Id","TeachingTaskId","RegularWeight","FinalWeight",
"Status","ReviewComment","SubmittedAt","ReviewedAt","PublishedAt","CreatedAt","UpdatedAt")
SELECT "Id","TeachingTaskId","RegularWeight","FinalWeight",
"Status","ReviewComment","SubmittedAt","ReviewedAt","PublishedAt","CreatedAt","UpdatedAt"
FROM "GradeSheets";
""",
"DROP TABLE \"GradeSheets\";",
"ALTER TABLE \"GradeSheets_v2\" RENAME TO \"GradeSheets\";",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeSheets_TeachingTaskId"
ON "GradeSheets" ("TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeSheets_Status"
ON "GradeSheets" ("Status");
""",
"""
CREATE TABLE "GradeRecords_v2" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeRecords" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"RegularScore" TEXT NULL,
"FinalScore" TEXT NULL,
"TotalScore" TEXT NULL,
"GradePoint" TEXT NULL,
"ExamStatus" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeRecords_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
INSERT INTO "GradeRecords_v2" ("Id","GradeSheetId","StudentId","RegularScore","FinalScore",
"TotalScore","GradePoint","ExamStatus","Notes","CreatedAt","UpdatedAt")
SELECT "Id","GradeSheetId","StudentId","RegularScore","FinalScore",
"TotalScore","GradePoint","ExamStatus","Notes","CreatedAt","UpdatedAt"
FROM "GradeRecords";
""",
"DROP TABLE \"GradeRecords\";",
"ALTER TABLE \"GradeRecords_v2\" RENAME TO \"GradeRecords\";",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeRecords_GradeSheetId_StudentId"
ON "GradeRecords" ("GradeSheetId", "StudentId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeRecords_StudentId_TotalScore"
ON "GradeRecords" ("StudentId", "TotalScore");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeItems" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Weight" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeItems_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeItems_GradeSheetId_SortOrder"
ON "GradeItems" ("GradeSheetId", "SortOrder");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeItemScores" (
"GradeRecordId" TEXT NOT NULL,
"GradeItemId" TEXT NOT NULL,
"Score" TEXT NULL,
CONSTRAINT "PK_GradeItemScores" PRIMARY KEY ("GradeRecordId", "GradeItemId"),
CONSTRAINT "FK_GradeItemScores_GradeRecords_GradeRecordId"
FOREIGN KEY ("GradeRecordId") REFERENCES "GradeRecords" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeItemScores_GradeItems_GradeItemId"
FOREIGN KEY ("GradeItemId") REFERENCES "GradeItems" ("Id") ON DELETE RESTRICT
);
"""
];
private static readonly string[] AttendanceStatements =
[
"""
CREATE TABLE IF NOT EXISTS "AttendanceSheets" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"AttendanceDate" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
"SubmittedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_AttendanceSheets_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceSheets_TeachingTaskId_AttendanceDate"
ON "AttendanceSheets" ("TeachingTaskId", "AttendanceDate");
""",
"""
CREATE TABLE IF NOT EXISTS "AttendanceRecords" (
"AttendanceSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"Notes" TEXT NULL,
CONSTRAINT "PK_AttendanceRecords" PRIMARY KEY ("AttendanceSheetId", "StudentId"),
CONSTRAINT "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId"
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_AttendanceRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE INDEX IF NOT EXISTS "IX_AttendanceRecords_AttendanceSheetId_StudentId"
ON "AttendanceRecords" ("AttendanceSheetId", "StudentId");
"""
];
private static readonly string[] StudentStatusChangesStatements = private static readonly string[] StudentStatusChangesStatements =
[ [
""" """
@@ -0,0 +1,184 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class FlexibleGradesAndAttendance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GradeItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
Weight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeItems", x => x.Id);
table.ForeignKey(
name: "FK_GradeItems_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GradeItemScores",
columns: table => new
{
GradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
GradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeItemScores", x => new { x.GradeRecordId, x.GradeItemId });
table.ForeignKey(
name: "FK_GradeItemScores_GradeItems_GradeItemId",
column: x => x.GradeItemId,
principalTable: "GradeItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_GradeItemScores_GradeRecords_GradeRecordId",
column: x => x.GradeRecordId,
principalTable: "GradeRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GradeItems_GradeSheetId_SortOrder",
table: "GradeItems",
columns: new[] { "GradeSheetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_GradeItemScores_GradeRecordId_GradeItemId",
table: "GradeItemScores",
columns: new[] { "GradeRecordId", "GradeItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GradeItemScores_GradeItemId",
table: "GradeItemScores",
column: "GradeItemId");
migrationBuilder.DropColumn(
name: "MidtermWeight",
table: "GradeSheets");
migrationBuilder.DropColumn(
name: "MidtermScore",
table: "GradeRecords");
migrationBuilder.CreateTable(
name: "AttendanceSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
AttendanceDate = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceSheets", x => x.Id);
table.ForeignKey(
name: "FK_AttendanceSheets_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "AttendanceRecords",
columns: table => new
{
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AttendanceRecords", x => new { x.AttendanceSheetId, x.StudentId });
table.ForeignKey(
name: "FK_AttendanceRecords_AttendanceSheets_AttendanceSheetId",
column: x => x.AttendanceSheetId,
principalTable: "AttendanceSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AttendanceRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_AttendanceSheets_TeachingTaskId_AttendanceDate",
table: "AttendanceSheets",
columns: new[] { "TeachingTaskId", "AttendanceDate" });
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_AttendanceSheetId_StudentId",
table: "AttendanceRecords",
columns: new[] { "AttendanceSheetId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AttendanceRecords_StudentId",
table: "AttendanceRecords",
column: "StudentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AttendanceRecords");
migrationBuilder.DropTable(name: "AttendanceSheets");
migrationBuilder.DropTable(name: "GradeItemScores");
migrationBuilder.DropTable(name: "GradeItems");
migrationBuilder.AddColumn<decimal>(
name: "MidtermWeight",
table: "GradeSheets",
type: "decimal(5,1)",
precision: 5,
scale: 1,
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "MidtermScore",
table: "GradeRecords",
type: "decimal(5,1)",
precision: 5,
scale: 1,
nullable: true);
}
}
}
+1
View File
@@ -20,6 +20,7 @@ declare module 'vue' {
ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
+2
View File
@@ -106,6 +106,8 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
), ),
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }), ...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }), ...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
...whenVisible(isTeacher.value || isTeachingAdmin.value, { path: '/teacher-attendance', label: '教学点名' }),
...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }),
...whenVisible(!isStudent.value, { ...whenVisible(!isStudent.value, {
path: '/class-timetable', path: '/class-timetable',
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询', label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
+12
View File
@@ -149,6 +149,18 @@ const router = createRouter({
component: () => import('../views/TimetableView.vue'), component: () => import('../views/TimetableView.vue'),
meta: { teacherView: true }, meta: { teacherView: true },
}, },
{
path: 'teacher-roster',
name: 'teacher-roster',
component: () => import('../views/TeacherRosterView.vue'),
meta: { roles: ['Teacher'] },
},
{
path: 'teacher-attendance',
name: 'teacher-attendance',
component: () => import('../views/TeacherAttendanceView.vue'),
meta: { roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'] },
},
{ {
path: 'free-classrooms', path: 'free-classrooms',
name: 'free-classrooms', name: 'free-classrooms',
+126 -41
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
Check, Check,
Delete,
DocumentChecked, DocumentChecked,
EditPen, EditPen,
Plus, Plus,
@@ -28,11 +29,15 @@ const termId = ref<string | undefined>()
const status = ref<string | undefined>() const status = ref<string | undefined>()
const createForm = reactive({ const createForm = reactive({
regularWeight: 30, regularWeight: 30,
midtermWeight: 0,
finalWeight: 70, finalWeight: 70,
items: [] as { name: string; weight: number }[],
}) })
const newItemName = ref('')
const newItemWeight = ref(0)
const returnComment = ref('') const returnComment = ref('')
const presetItems = ['实验', '实习', '课程设计', '作业', '课堂表现', '期中']
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
Draft: '录入中', Draft: '录入中',
Submitted: '待审核', Submitted: '待审核',
@@ -46,6 +51,10 @@ const examStatusLabels: Record<string, string> = {
Deferred: '缓考', Deferred: '缓考',
Exempt: '免修', Exempt: '免修',
} }
const itemsWeight = computed(() =>
createForm.items.reduce((sum, item) => sum + item.weight, 0))
const weightSum = computed(() =>
createForm.regularWeight + createForm.finalWeight + itemsWeight.value)
const completedCount = computed(() => const completedCount = computed(() =>
detail.value?.records.filter((record: any) => detail.value?.records.filter((record: any) =>
record.totalScore != null || record.examStatus !== 'Normal').length ?? 0) record.totalScore != null || record.examStatus !== 'Normal').length ?? 0)
@@ -69,8 +78,7 @@ const transcriptGpa = computed(() => {
const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0) const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0)
if (!credits) return '—' if (!credits) return '—'
const points = numeric.reduce( const points = numeric.reduce(
(sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), (sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits), 0,
0,
) )
return (points / credits).toFixed(2) return (points / credits).toFixed(2)
}) })
@@ -79,6 +87,29 @@ const earnedCredits = computed(() =>
.filter((record: any) => Number(record.totalScore) >= 60) .filter((record: any) => Number(record.totalScore) >= 60)
.reduce((sum: number, record: any) => sum + Number(record.credits), 0)) .reduce((sum: number, record: any) => sum + Number(record.credits), 0))
function addItem() {
const name = newItemName.value.trim() || '自定义分项'
if (createForm.items.some(item => item.name === name)) {
ElMessage.warning('分项名称不能重复。')
return
}
createForm.items.push({ name, weight: newItemWeight.value || 0 })
newItemName.value = ''
newItemWeight.value = 0
}
function addPresetItem(name: string) {
if (createForm.items.some(item => item.name === name)) {
ElMessage.warning('该分项已存在。')
return
}
createForm.items.push({ name, weight: 0 })
}
function removeItem(index: number) {
createForm.items.splice(index, 1)
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -127,23 +158,25 @@ async function selectTask(task: any) {
} }
function openCreate() { function openCreate() {
Object.assign(createForm, { createForm.regularWeight = 30
regularWeight: 30, createForm.finalWeight = 70
midtermWeight: 0, createForm.items = []
finalWeight: 70, newItemName.value = ''
}) newItemWeight.value = 0
createDialog.value = true createDialog.value = true
} }
async function createSheet() { async function createSheet() {
if (createForm.regularWeight + createForm.midtermWeight + createForm.finalWeight !== 100) { if (weightSum.value !== 100) {
ElMessage.warning('三个成绩分项的比例必须合计 100%。') ElMessage.warning(`平时、期末与所有分项的比例必须合计 100%(当前 ${weightSum.value}%)。`)
return return
} }
try { try {
await http.post('/grades/sheets', { await http.post('/grades/sheets', {
teachingTaskId: selectedTask.value.id, teachingTaskId: selectedTask.value.id,
...createForm, regularWeight: createForm.regularWeight,
finalWeight: createForm.finalWeight,
items: createForm.items.map(item => ({ name: item.name, weight: item.weight })),
}) })
createDialog.value = false createDialog.value = false
ElMessage.success('成绩登记册已建立') ElMessage.success('成绩登记册已建立')
@@ -159,8 +192,11 @@ async function saveRecords() {
records: detail.value.records.map((record: any) => ({ records: detail.value.records.map((record: any) => ({
id: record.id, id: record.id,
regularScore: record.regularScore, regularScore: record.regularScore,
midtermScore: record.midtermScore,
finalScore: record.finalScore, finalScore: record.finalScore,
itemScores: record.itemScores?.map((itemScore: any) => ({
gradeItemId: itemScore.gradeItemId,
score: itemScore.score,
})),
examStatus: record.examStatus, examStatus: record.examStatus,
notes: record.notes, notes: record.notes,
})), })),
@@ -241,6 +277,10 @@ function scoreClass(score: number | null) {
return '' return ''
} }
function itemScore(record: any, gradeItemId: string) {
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
}
onMounted(async () => { onMounted(async () => {
try { try {
terms.value = (await http.get('/base-data/terms')).data terms.value = (await http.get('/base-data/terms')).data
@@ -259,7 +299,7 @@ onMounted(async () => {
<span class="section-kicker">ACADEMIC RECORD</span> <span class="section-kicker">ACADEMIC RECORD</span>
<h2>{{ isStudent ? '学业成绩单' : '成绩管理' }}</h2> <h2>{{ isStudent ? '学业成绩单' : '成绩管理' }}</h2>
<p v-if="isStudent">查看学校已正式发布的课程成绩学分与绩点</p> <p v-if="isStudent">查看学校已正式发布的课程成绩学分与绩点</p>
<p v-else>从教师登记学院复核到校级发布保留每张成绩单的明确状态</p> <p v-else>从教师登记学院复核到校级发布支持自定义平时实验实习等分项</p>
</div> </div>
<el-button :icon="Refresh" @click="load">刷新</el-button> <el-button :icon="Refresh" @click="load">刷新</el-button>
</section> </section>
@@ -366,7 +406,7 @@ onMounted(async () => {
<div><span>及格率</span><b>{{ passRate }}</b></div> <div><span>及格率</span><b>{{ passRate }}</b></div>
<p> <p>
平时 {{ detail.regularWeight }}% 平时 {{ detail.regularWeight }}%
<template v-if="detail.midtermWeight"> · 期中 {{ detail.midtermWeight }}%</template> <template v-if="detail.items?.length">{{ detail.items.map((item: any) => ` · ${item.name} ${item.weight}%`).join('') }}</template>
· 期末 {{ detail.finalWeight }}% · 期末 {{ detail.finalWeight }}%
</p> </p>
</section> </section>
@@ -389,58 +429,58 @@ onMounted(async () => {
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="125"> <el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'" v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.regularScore" v-model="row.regularScore"
:min="0" :min="0" :max="100" :controls="false" size="small"
:max="100"
:controls="false"
/> />
<span v-else>{{ row.regularScore ?? '' }}</span> <span v-else>{{ row.regularScore ?? '' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="detail.midtermWeight" :label="`期中 ${detail.midtermWeight}%`" width="125"> <el-table-column
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
width="110"
>
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'" v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.midtermScore" :model-value="itemScore(row, item.id)?.score"
:min="0" @update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
:max="100" :min="0" :max="100" :controls="false" size="small"
:controls="false"
/> />
<span v-else>{{ row.midtermScore ?? '' }}</span> <span v-else>{{ itemScore(row, item.id)?.score ?? '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="125"> <el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
<template #default="{ row }"> <template #default="{ row }">
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'" v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore" v-model="row.finalScore"
:min="0" :min="0" :max="100" :controls="false" size="small"
:max="100"
:controls="false"
/> />
<span v-else>{{ row.finalScore ?? '—' }}</span> <span v-else>{{ row.finalScore ?? '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="总评" width="90"> <el-table-column label="总评" width="80">
<template #default="{ row }"> <template #default="{ row }">
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b> <b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="考试状态" width="125"> <el-table-column label="考试状态" width="115">
<template #default="{ row }"> <template #default="{ row }">
<el-select v-if="detail.canEdit" v-model="row.examStatus"> <el-select v-if="detail.canEdit" v-model="row.examStatus" size="small">
<el-option v-for="(label, value) in examStatusLabels" :key="value" :label="label" :value="value" /> <el-option v-for="(label, value) in examStatusLabels" :key="value" :label="label" :value="value" />
</el-select> </el-select>
<span v-else>{{ examStatusLabels[row.examStatus] }}</span> <span v-else>{{ examStatusLabels[row.examStatus] }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="备注" min-width="150"> <el-table-column label="备注" min-width="140">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" /> <el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" size="small" />
<span v-else>{{ row.notes || '—' }}</span> <span v-else>{{ row.notes || '—' }}</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -463,24 +503,54 @@ onMounted(async () => {
</section> </section>
</template> </template>
<el-dialog v-model="createDialog" title="建立成绩登记册" width="560px"> <el-dialog v-model="createDialog" title="建立成绩登记册" width="620px">
<el-alert title="成绩构成比例合计必须为 100%" type="info" :closable="false" /> <el-alert title="平时期末与所有分项的比例必须合计 100%" type="info" :closable="false" />
<el-form label-position="top" class="grade-weight-form"> <el-form label-position="top" class="grade-weight-form">
<div class="form-grid three"> <div class="form-grid three">
<el-form-item label="平时成绩"> <el-form-item label="平时成绩 %">
<el-input-number v-model="createForm.regularWeight" :min="0" :max="100" /> <el-input-number v-model="createForm.regularWeight" :min="0" :max="100" />
</el-form-item> </el-form-item>
<el-form-item label="期中成绩"> <el-form-item label="期末成绩 %">
<el-input-number v-model="createForm.midtermWeight" :min="0" :max="100" />
</el-form-item>
<el-form-item label="期末成绩">
<el-input-number v-model="createForm.finalWeight" :min="0" :max="100" /> <el-input-number v-model="createForm.finalWeight" :min="0" :max="100" />
</el-form-item> </el-form-item>
<el-form-item label="当前合计">
<div class="weight-sum" :class="{ invalid: weightSum !== 100 }">
<b>{{ weightSum }}%</b>
<span v-if="weightSum !== 100">需为 100%</span>
<span v-else class="valid">✓</span>
</div>
</el-form-item>
</div>
<el-divider>成绩分项(实验、实习、课程设计等)</el-divider>
<div class="preset-items">
<span>快速添加:</span>
<el-button
v-for="name in presetItems" :key="name"
size="small" @click="addPresetItem(name)"
>{{ name }}</el-button>
</div>
<div v-if="createForm.items.length" class="item-list">
<div v-for="(item, index) in createForm.items" :key="index" class="item-row">
<span>{{ item.name }}</span>
<el-input-number v-model="item.weight" :min="0" :max="100" size="small" />
<span>%</span>
<el-button :icon="Delete" link type="danger" @click="removeItem(index)" />
</div>
</div>
<div class="add-item-row">
<el-input v-model="newItemName" placeholder="自定义名称" size="small" style="width:160px" />
<el-input-number v-model="newItemWeight" :min="0" :max="100" size="small" />
<span>%</span>
<el-button size="small" :icon="Plus" @click="addItem">添加</el-button>
</div> </div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createDialog = false">取消</el-button> <el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" @click="createSheet">建立登记册</el-button> <el-button type="primary" :disabled="weightSum !== 100" @click="createSheet">建立登记册</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -497,3 +567,18 @@ onMounted(async () => {
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<style scoped>
.grade-weight-form { margin-top: 16px; }
.weight-sum { display: flex; align-items: baseline; gap: 8px; padding: 12px; border-radius: 6px; background: #f5f7fa; }
.weight-sum b { font: 700 22px/1 Consolas, monospace; color: var(--indigo); }
.weight-sum.invalid b { color: #b34e48; }
.weight-sum span { font-size: 11px; color: var(--muted); }
.weight-sum .valid { color: #2d8975; font-weight: 700; }
.preset-items { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.preset-items > span { font-size: 11px; color: var(--muted); }
.item-list { display: grid; gap: 6px; margin-bottom: 12px; }
.item-row { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #eef5f0; border-radius: 4px; }
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
.add-item-row { display: flex; align-items: center; gap: 8px; }
</style>
+391
View File
@@ -0,0 +1,391 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Check, Download, Plus, Refresh, Upload } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
const loading = ref(false)
const detailLoading = ref(false)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const sheets = ref<any[]>([])
const selectedTask = ref<any>(null)
const selectedSheet = ref<any>(null)
const sheetDetail = ref<any>(null)
const createDialog = ref(false)
const fileInput = ref<HTMLInputElement>()
const termId = ref<string>()
const createForm = ref({ name: '', attendanceDate: '' })
const statusLabels: Record<number, string> = {
1: '出勤', 2: '缺勤', 3: '迟到', 4: '请假', 5: '免修',
}
async function loadTasks() {
loading.value = true
try {
tasks.value = (await http.get('/attendance/my-tasks', {
params: { academicTermId: termId.value || undefined },
})).data
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
?? tasks.value[0]
if (preferred) await selectTask(preferred)
else {
selectedTask.value = null
sheets.value = []
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectTask(task: any) {
selectedTask.value = task
selectedSheet.value = null
sheetDetail.value = null
try {
sheets.value = (await http.get('/attendance/sheets', {
params: { teachingTaskId: task.id },
})).data
} catch (error) {
sheets.value = []
ElMessage.error(apiErrorMessage(error))
}
}
async function selectSheet(sheet: any) {
selectedSheet.value = sheet
detailLoading.value = true
try {
sheetDetail.value = (await http.get(`/attendance/sheets/${sheet.id}`)).data
} catch (error) {
sheetDetail.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
function openCreate() {
createForm.value = {
name: '',
attendanceDate: new Date().toISOString().slice(0, 10),
}
createDialog.value = true
}
async function createSheet() {
if (!createForm.value.name.trim()) {
ElMessage.warning('请填写考勤表名称。')
return
}
try {
await http.post('/attendance/sheets', {
teachingTaskId: selectedTask.value.id,
name: createForm.value.name,
attendanceDate: new Date(createForm.value.attendanceDate).toISOString(),
})
createDialog.value = false
ElMessage.success('考勤表已建立')
await selectTask(selectedTask.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function saveRecords() {
if (!sheetDetail.value) return
try {
await http.put(`/attendance/sheets/${sheetDetail.value.sheet.id}/records`, {
records: sheetDetail.value.sheet.records.map((record: any) => ({
studentId: record.studentId,
status: record.status,
notes: record.notes,
})),
})
ElMessage.success('考勤记录已保存')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function submitSheet() {
try {
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/submit`)
ElMessage.success('考勤表已提交')
await selectTask(selectedTask.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function removeSheet(sheet: any) {
try {
await ElMessageBox.confirm(`确定删除考勤表"${sheet.name}"吗?`, '删除考勤表', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
await http.delete(`/attendance/sheets/${sheet.id}`)
ElMessage.success('考勤表已删除')
await selectTask(selectedTask.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function chooseImportFile() { fileInput.value?.click() }
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !sheetDetail.value) return
try {
const { data } = await importExcel(
`/attendance/sheets/${sheetDetail.value.sheet.id}/import`,
file,
)
ElMessage.success(`已从 Excel 更新 ${data.updated} 条考勤记录`)
await selectSheet(selectedSheet.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function exportSheet() {
if (!selectedSheet.value) return
try {
await downloadApiFile(
`/attendance/sheets/${selectedSheet.value.id}/export.xlsx`,
`考勤表-${selectedSheet.value.name}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function batchStatus(status: number) {
if (!sheetDetail.value?.sheet?.records) return
sheetDetail.value.sheet.records.forEach((record: any) => {
record.status = status
})
}
function countStatus(status: number) {
return sheetDetail.value?.sheet?.records?.filter((r: any) => r.status === status).length ?? 0
}
onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
await loadTasks()
})
</script>
<template>
<div class="page-stack attendance-page">
<section class="page-intro">
<div>
<span class="section-kicker">ATTENDANCE</span>
<h2>教学点名</h2>
<p>按教学班创建考勤表在线点名或从 Excel 导入考勤结果</p>
</div>
<el-button :icon="Refresh" @click="loadTasks">刷新</el-button>
</section>
<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-select>
<span> {{ tasks.length }} 个教学班</span>
</section>
<section class="attendance-workspace" v-loading="loading">
<aside class="attendance-task-list">
<button
v-for="task in tasks"
:key="task.id"
type="button"
:class="{ active: selectedTask?.id === task.id }"
@click="selectTask(task)"
>
<span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
<b>{{ task.courseName }}</b>
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
</button>
<el-empty v-if="!tasks.length" description="没有可点名的教学班" />
</aside>
<main class="attendance-detail">
<template v-if="!selectedTask">
<el-empty description="请选择一个教学班" />
</template>
<template v-else>
<header class="attendance-subhead">
<div>
<strong>{{ selectedTask.courseName }}</strong>
<span>{{ selectedTask.taskNumber }} · {{ selectedTask.studentCount }} 名学生</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
</header>
<div class="attendance-sheet-list">
<button
v-for="sheet in sheets"
:key="sheet.id"
type="button"
:class="{ active: selectedSheet?.id === sheet.id }"
@click="selectSheet(sheet)"
>
<div>
<b>{{ sheet.name }}</b>
<span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span>
</div>
<div class="sheet-stats">
<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>
<span class="leave" v-if="sheet.leaveCount">请假 {{ sheet.leaveCount }}</span>
</div>
<i>{{ sheet.status === 2 ? '已提交' : '草稿' }}</i>
<el-button
v-if="sheet.status === 1"
link type="danger" size="small"
@click.stop="removeSheet(sheet)"
>删除</el-button>
</button>
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
</div>
</template>
</main>
<aside v-if="sheetDetail" class="attendance-panel" v-loading="detailLoading">
<header>
<div>
<strong>{{ sheetDetail.sheet.name }}</strong>
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
<small>{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ sheetDetail.sheet.status === 2 ? '已提交' : '草稿' }}</small>
</div>
<div class="panel-actions">
<el-button
v-if="sheetDetail.canEdit"
size="small" :icon="Upload"
@click="chooseImportFile"
>导入 Excel</el-button>
<input ref="fileInput" class="visually-hidden" type="file" accept=".xlsx" @change="handleImport" />
<el-button size="small" :icon="Download" @click="exportSheet">导出 Excel</el-button>
<el-button v-if="sheetDetail.canEdit" type="primary" size="small" :icon="Check" @click="saveRecords">保存</el-button>
<el-button v-if="sheetDetail.canEdit" type="success" size="small" @click="submitSheet">提交</el-button>
</div>
</header>
<div v-if="sheetDetail.canEdit" class="batch-row">
<span>批量设置</span>
<el-button size="small" @click="batchStatus(1)">全部出勤</el-button>
<el-button size="small" @click="batchStatus(2)">全部缺勤</el-button>
<el-button size="small" @click="batchStatus(3)">全部迟到</el-button>
<small>出勤 {{ countStatus(1) }} · 缺勤 {{ countStatus(2) }} · 迟到 {{ countStatus(3) }} · 请假 {{ countStatus(4) }} · 免修 {{ countStatus(5) }}</small>
</div>
<el-table :data="sheetDetail.sheet.records" class="data-table" size="small">
<el-table-column label="学号" width="120">
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="80" />
<el-table-column label="考勤" width="110">
<template #default="{ row }">
<el-select
v-if="sheetDetail.canEdit"
v-model="row.status" size="small"
>
<el-option v-for="(label, val) in statusLabels" :key="val" :label="label" :value="Number(val)" />
</el-select>
<span v-else>{{ statusLabels[row.status] }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row }">
<el-input v-if="sheetDetail.canEdit" v-model="row.notes" size="small" maxlength="300" />
<span v-else>{{ row.notes || '' }}</span>
</template>
</el-table-column>
</el-table>
</aside>
</section>
<el-dialog v-model="createDialog" title="新建考勤表" width="500px">
<el-form label-position="top">
<el-form-item label="考勤名称" required>
<el-input v-model="createForm.name" placeholder="如:第3周课堂点名" maxlength="120" />
</el-form-item>
<el-form-item label="考勤日期" required>
<el-date-picker v-model="createForm.attendanceDate" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" @click="createSheet">建立考勤表</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.attendance-toolbar {
min-height: 56px; padding: 10px 16px;
display: flex; align-items: center; gap: 12px;
border: 1px solid var(--line); background: #fbfcfd;
}
.attendance-toolbar .el-select { width: 260px; }
.attendance-toolbar > span { color: var(--muted); font-size: 12px; }
.attendance-workspace { display: grid; grid-template-columns: 260px minmax(0, 1fr) 420px; min-height: 500px; border: 1px solid var(--line); background: white; }
.attendance-task-list { border-right: 1px solid var(--line); overflow-y: auto; max-height: 650px; }
.attendance-task-list button {
display: grid; gap: 3px; width: 100%; padding: 12px 14px; border: none; border-bottom: 1px solid #edf0f4;
background: none; cursor: pointer; text-align: left; transition: background .15s;
}
.attendance-task-list button:hover { background: #f5f7fa; }
.attendance-task-list button.active { background: #e9f3f5; border-left: 3px solid #176b87; }
.attendance-task-list button > span { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; }
.attendance-task-list button > b { font-size: 13px; }
.attendance-task-list button > small { color: var(--muted); font-size: 10px; }
.attendance-detail { display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.attendance-subhead {
padding: 14px 18px; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--line); background: #f8fafb;
}
.attendance-subhead strong { font-size: 15px; }
.attendance-subhead span { color: var(--muted); font-size: 11px; display: block; }
.attendance-sheet-list { flex: 1; overflow-y: auto; }
.attendance-sheet-list > button {
width: 100%; padding: 12px 16px; border: none; border-bottom: 1px solid #edf0f4;
background: none; cursor: pointer; text-align: left; display: grid; gap: 6px; transition: background .15s;
}
.attendance-sheet-list > button:hover { background: #f5f7fa; }
.attendance-sheet-list > button.active { background: #eef5f0; border-left: 3px solid #2d8975; }
.attendance-sheet-list > button > div { display: flex; align-items: baseline; justify-content: space-between; }
.attendance-sheet-list > button b { font-size: 14px; }
.attendance-sheet-list > button span { color: var(--muted); font-size: 11px; }
.attendance-sheet-list > button i { color: var(--indigo); font-size: 10px; font-weight: 700; font-style: normal; }
.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 .absent { color: #b34e48; }
.sheet-stats .late { color: #c78724; }
.sheet-stats .leave { color: #79579a; }
.attendance-panel { overflow-y: auto; max-height: 650px; }
.attendance-panel > header {
padding: 14px 16px; border-bottom: 1px solid var(--line); background: #f8fafb;
}
.attendance-panel > header strong { font-size: 15px; display: block; }
.attendance-panel > header span { color: var(--muted); font-size: 10px; }
.attendance-panel > header small { color: var(--indigo); font-size: 10px; display: block; }
.panel-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.batch-row { padding: 8px 14px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #edf0f4; background: #fff; }
.batch-row > span { font-size: 11px; color: var(--muted); }
.batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); }
@media (max-width: 1100px) {
.attendance-workspace { grid-template-columns: 1fr; }
.attendance-task-list { max-height: 200px; }
.attendance-sheet-list { max-height: 200px; }
.attendance-panel { max-height: 400px; }
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Download, Refresh } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/excel'
const loading = ref(false)
const detailLoading = ref(false)
const terms = ref<any[]>([])
const offerings = ref<any[]>([])
const selectedOffering = ref<any>(null)
const roster = ref<any>(null)
const termId = ref<string>()
async function loadOfferings() {
loading.value = true
try {
offerings.value = (await http.get('/course-selections/my-offerings', {
params: { academicTermId: termId.value || undefined },
})).data
const preferred = offerings.value.find((item: any) => item.id === selectedOffering.value?.id)
?? offerings.value[0]
if (preferred) await selectOffering(preferred)
else {
selectedOffering.value = null
roster.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectOffering(offering: any) {
selectedOffering.value = offering
detailLoading.value = true
try {
roster.value = (await http.get(`/course-selections/offerings/${offering.id}/roster`)).data
} catch (error) {
roster.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function exportRoster() {
if (!selectedOffering.value) return
try {
await downloadApiFile(
`/course-selections/offerings/${selectedOffering.value.id}/roster/export.xlsx`,
`选课名单-${selectedOffering.value.taskNumber}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(async () => {
terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
await loadOfferings()
})
</script>
<template>
<div class="page-stack roster-page">
<section class="page-intro">
<div>
<span class="section-kicker">ENROLLMENT ROSTER</span>
<h2>选课名单</h2>
<p>查看本人授课班级的选课学生名单支持 Excel 导出</p>
</div>
<el-button :icon="Refresh" @click="loadOfferings">刷新</el-button>
</section>
<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-select>
<span> {{ offerings.length }} 个教学班</span>
</section>
<section class="roster-workspace" v-loading="loading">
<aside class="roster-task-list">
<button
v-for="offering in offerings"
:key="offering.id"
type="button"
:class="{ active: selectedOffering?.id === offering.id }"
@click="selectOffering(offering)"
>
<span>{{ offering.courseCode }} · {{ offering.taskNumber }}</span>
<b>{{ offering.courseName }}</b>
<small>{{ offering.termName }} · {{ offering.roundName }}</small>
<i>{{ offering.enrolledCount }} / {{ offering.capacity }} </i>
</button>
<el-empty v-if="!offerings.length" description="当前学期没有选课教学班" />
</aside>
<main class="roster-detail" v-loading="detailLoading">
<el-empty v-if="!selectedOffering" description="请选择一个教学班" />
<template v-else-if="roster">
<header class="roster-head">
<div>
<span>{{ roster.courseCode }} · {{ roster.taskNumber }}</span>
<h3>{{ roster.courseName }}选课名单</h3>
<p>已选 {{ roster.enrolledCount }} / 容量 {{ roster.capacity }} </p>
</div>
<el-button :icon="Download" @click="exportRoster">导出 Excel</el-button>
</header>
<el-table :data="roster.students" class="data-table">
<el-table-column label="学号" width="135">
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
</el-table-column>
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column prop="className" label="班级" min-width="130" />
<el-table-column prop="majorName" label="专业" min-width="150" />
<el-table-column label="选课时间" width="170">
<template #default="{ row }">{{ new Date(row.enrolledAt).toLocaleString('zh-CN') }}</template>
</el-table-column>
</el-table>
</template>
</main>
</section>
</div>
</template>
<style scoped>
.roster-toolbar {
min-height: 56px; padding: 10px 16px;
display: flex; align-items: center; gap: 12px;
border: 1px solid var(--line); background: #fbfcfd;
}
.roster-toolbar .el-select { width: 260px; }
.roster-toolbar > span { color: var(--muted); font-size: 12px; }
.roster-workspace { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 420px; border: 1px solid var(--line); background: white; }
.roster-task-list { border-right: 1px solid var(--line); overflow-y: auto; max-height: 600px; }
.roster-task-list button {
display: grid; gap: 3px; width: 100%; padding: 14px 16px; border: none; border-bottom: 1px solid #edf0f4;
background: none; cursor: pointer; text-align: left; transition: background .15s;
}
.roster-task-list button:hover { background: #f5f7fa; }
.roster-task-list button.active { background: #e9f3f5; border-left: 3px solid #176b87; }
.roster-task-list button > span { color: var(--teal); font: 700 10px/1.2 Consolas, monospace; }
.roster-task-list button > b { font-size: 14px; }
.roster-task-list button > small { color: var(--muted); font-size: 11px; }
.roster-task-list button > i { color: var(--indigo); font-size: 10px; font-weight: 700; }
.roster-detail { min-height: 420px; }
.roster-head {
padding: 18px 22px; display: flex; align-items: center; justify-content: space-between; gap: 16px;
border-bottom: 1px solid var(--line); background: #f8fafb;
}
.roster-head h3 { margin: 5px 0 4px; font-size: 18px; }
.roster-head p { color: var(--muted); font-size: 12px; margin: 0; }
.roster-head span { color: var(--teal); font-size: 10px; font-weight: 700; }
@media (max-width: 760px) {
.roster-workspace { grid-template-columns: 1fr; }
.roster-task-list { max-height: 220px; border-right: none; border-bottom: 1px solid var(--line); }
}
</style>