成绩+点名
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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 =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
|
|||||||
+184
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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']
|
||||||
|
|||||||
@@ -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 ? '课表查询中心' : '班级课表查询',
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user