已完成“成绩管理与学业档案”模块:
教师批量录入平时、期中、期末成绩。 自动计算总评、绩点和及格状态。 支持缺考、缓考、免修。 教师提交 → 学院审核/退回 → 校级发布。 学生只能查看正式发布成绩。 自动统计平均分、完成度、及格率和加权 GPA。 SQLite 本地增量升级和 MySQL 正式迁移均已完成。 分级权限在前后端同时校验。
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
|
||||
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单。
|
||||
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布锁定和旧版本归档;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课;排课支持单双周、周次节次、教室容量、教师/行政班/教室冲突校验和版本化发布;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间和实时教学班名单;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单。
|
||||
|
||||
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
|
||||
|
||||
|
||||
@@ -222,6 +222,94 @@ try {
|
||||
throw 'Course-selection roster did not include the selected student.'
|
||||
}
|
||||
|
||||
$gradeTasks = Invoke-RestMethod `
|
||||
-Uri 'http://localhost:5255/api/grades/sheets' `
|
||||
-Headers $headers
|
||||
$gradeTask = @($gradeTasks) |
|
||||
Where-Object { $null -ne $_.sheet } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $gradeTask) {
|
||||
throw 'Development grade sheet was not seeded.'
|
||||
}
|
||||
$gradeDetail = Invoke-RestMethod `
|
||||
-Uri "http://localhost:5255/api/grades/sheets/$($gradeTask.sheet.id)" `
|
||||
-Headers $headers
|
||||
if (@($gradeDetail.sheet.records).Count -lt 1) {
|
||||
throw 'Development grade sheet has no student records.'
|
||||
}
|
||||
|
||||
$teacherLoginBody = @{
|
||||
userName = 'teacher'
|
||||
password = 'Teacher@123456'
|
||||
} | ConvertTo-Json
|
||||
$teacherLogin = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri 'http://localhost:5255/api/auth/login' `
|
||||
-ContentType 'application/json' `
|
||||
-Body $teacherLoginBody
|
||||
$teacherHeaders = @{ Authorization = "Bearer $($teacherLogin.token)" }
|
||||
$teacherGradeTasks = Invoke-RestMethod `
|
||||
-Uri 'http://localhost:5255/api/grades/sheets' `
|
||||
-Headers $teacherHeaders
|
||||
$teacherGradeTask = @($teacherGradeTasks) |
|
||||
Where-Object { $_.id -eq $gradeTask.id } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $teacherGradeTask) {
|
||||
throw 'Assigned teacher cannot access the grade sheet.'
|
||||
}
|
||||
if ($teacherGradeTask.sheet.status -eq 'Draft' -or
|
||||
$teacherGradeTask.sheet.status -eq 'Returned') {
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "http://localhost:5255/api/grades/sheets/$($teacherGradeTask.sheet.id)/submit" `
|
||||
-Headers $teacherHeaders |
|
||||
Out-Null
|
||||
}
|
||||
|
||||
$collegeLoginBody = @{
|
||||
userName = 'college'
|
||||
password = 'College@123456'
|
||||
} | ConvertTo-Json
|
||||
$collegeLogin = Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri 'http://localhost:5255/api/auth/login' `
|
||||
-ContentType 'application/json' `
|
||||
-Body $collegeLoginBody
|
||||
$collegeHeaders = @{ Authorization = "Bearer $($collegeLogin.token)" }
|
||||
$collegeGradeTasks = Invoke-RestMethod `
|
||||
-Uri 'http://localhost:5255/api/grades/sheets' `
|
||||
-Headers $collegeHeaders
|
||||
$collegeGradeTask = @($collegeGradeTasks) |
|
||||
Where-Object { $_.id -eq $gradeTask.id } |
|
||||
Select-Object -First 1
|
||||
if ($collegeGradeTask.sheet.status -eq 'Submitted') {
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "http://localhost:5255/api/grades/sheets/$($collegeGradeTask.sheet.id)/approve" `
|
||||
-Headers $collegeHeaders |
|
||||
Out-Null
|
||||
}
|
||||
|
||||
$adminGradeTasks = Invoke-RestMethod `
|
||||
-Uri 'http://localhost:5255/api/grades/sheets' `
|
||||
-Headers $headers
|
||||
$adminGradeTask = @($adminGradeTasks) |
|
||||
Where-Object { $_.id -eq $gradeTask.id } |
|
||||
Select-Object -First 1
|
||||
if ($adminGradeTask.sheet.status -eq 'Approved') {
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri "http://localhost:5255/api/grades/sheets/$($adminGradeTask.sheet.id)/publish" `
|
||||
-Headers $headers |
|
||||
Out-Null
|
||||
}
|
||||
$studentTranscript = Invoke-RestMethod `
|
||||
-Uri 'http://localhost:5255/api/grades/student/transcript' `
|
||||
-Headers $studentHeaders
|
||||
if (@($studentTranscript.records).Count -lt 1) {
|
||||
throw 'Published grade is not visible in the student transcript.'
|
||||
}
|
||||
|
||||
$frontend = Invoke-WebRequest -Uri 'http://localhost:5255/' -TimeoutSec 5
|
||||
$spaFallback = Invoke-WebRequest -Uri 'http://localhost:5255/base-data' -TimeoutSec 5
|
||||
$unknownApiParameters = @{
|
||||
@@ -250,6 +338,9 @@ try {
|
||||
SelectionOfferings = @($selectionOfferings).Count
|
||||
StudentEnrollments = $activeEnrollments.Count
|
||||
RosterStudents = $selectionRoster.enrolledCount
|
||||
GradeSheets = @($gradeTasks).Count
|
||||
GradeRecords = @($gradeDetail.sheet.records).Count
|
||||
TranscriptRecords = @($studentTranscript.records).Count
|
||||
AccessUpdate = $true
|
||||
ScopeChecks = $scopeChecks -join ', '
|
||||
StaticIndex = $frontend.Content.Contains('明序教务管理系统')
|
||||
|
||||
@@ -44,6 +44,11 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
|
||||
.CountAsync(
|
||||
x => x.Status == CourseEnrollmentStatus.Enrolled,
|
||||
cancellationToken),
|
||||
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
|
||||
PublishedGradeSheets = await db.GradeSheets.CountAsync(
|
||||
x => x.Status == GradeSheetStatus.Published,
|
||||
cancellationToken),
|
||||
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
|
||||
Users = await db.Users.CountAsync(cancellationToken)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Domain.Identity;
|
||||
using Jiaowu.Api.Infrastructure.Auth;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
using Jiaowu.Api.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jiaowu.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/grades")]
|
||||
public sealed class GradesController(
|
||||
AppDbContext db,
|
||||
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||
{
|
||||
private const string SheetUsers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin + "," +
|
||||
SystemRoles.Teacher;
|
||||
|
||||
private const string Reviewers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin + "," +
|
||||
SystemRoles.CollegeAdmin;
|
||||
|
||||
private const string Publishers =
|
||||
SystemRoles.SuperAdmin + "," +
|
||||
SystemRoles.AcademicAdmin;
|
||||
|
||||
[HttpGet("sheets")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheets(
|
||||
Guid? academicTermId,
|
||||
GradeSheetStatus? status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var source = AccessibleTasks().AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == TeachingTaskStatus.Published ||
|
||||
x.Status == TeachingTaskStatus.Closed);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x => x.AcademicTermId == academicTermId);
|
||||
|
||||
var items = await source
|
||||
.OrderByDescending(x => x.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.TaskNumber)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.TaskNumber,
|
||||
TaskName = x.Name,
|
||||
x.AcademicTermId,
|
||||
TermName = x.AcademicTerm!.Name,
|
||||
CourseCode = x.Course!.Code,
|
||||
CourseName = x.Course.Name,
|
||||
CollegeName = x.Course.College!.Name,
|
||||
x.Course.Credits,
|
||||
TeacherNames = x.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
|
||||
Sheet = db.GradeSheets
|
||||
.Where(sheet => sheet.TeachingTaskId == x.Id)
|
||||
.Select(sheet => new
|
||||
{
|
||||
sheet.Id,
|
||||
sheet.Status,
|
||||
sheet.RegularWeight,
|
||||
sheet.MidtermWeight,
|
||||
sheet.FinalWeight,
|
||||
StudentCount = sheet.Records.Count,
|
||||
CompletedCount = sheet.Records.Count(record =>
|
||||
record.TotalScore != null ||
|
||||
record.ExamStatus != GradeExamStatus.Normal),
|
||||
PassedCount = sheet.Records.Count(record =>
|
||||
record.TotalScore >= 60),
|
||||
sheet.SubmittedAt,
|
||||
sheet.ReviewedAt,
|
||||
sheet.PublishedAt,
|
||||
sheet.UpdatedAt
|
||||
})
|
||||
.FirstOrDefault()
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (status.HasValue)
|
||||
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpPost("sheets")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> CreateSheet(
|
||||
GradeSheetRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GradeCalculator.AreWeightsValid(
|
||||
request.RegularWeight,
|
||||
request.MidtermWeight,
|
||||
request.FinalWeight))
|
||||
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
|
||||
|
||||
var task = await AccessibleTasks()
|
||||
.Include(x => x.Teachers)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||
if (task is null) return NotFound();
|
||||
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
|
||||
return ConflictProblem("只有已发布或已结课的教学班可以建立成绩单。");
|
||||
if (!CanInitialize(task)) return Forbid();
|
||||
if (await db.GradeSheets.AnyAsync(
|
||||
x => x.TeachingTaskId == task.Id,
|
||||
cancellationToken))
|
||||
return ConflictProblem("该教学班已经建立成绩单。");
|
||||
|
||||
var studentIds = await db.CourseEnrollments.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.Status == CourseEnrollmentStatus.Enrolled &&
|
||||
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
|
||||
.Select(x => x.StudentId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
if (studentIds.Count == 0)
|
||||
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。");
|
||||
|
||||
var sheet = new GradeSheet
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
RegularWeight = request.RegularWeight,
|
||||
MidtermWeight = request.MidtermWeight,
|
||||
FinalWeight = request.FinalWeight,
|
||||
Records = studentIds.Select(studentId => new GradeRecord
|
||||
{
|
||||
StudentId = studentId
|
||||
}).ToList()
|
||||
};
|
||||
db.GradeSheets.Add(sheet);
|
||||
return await SaveAsync(sheet.Id, true, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("sheets/{id:guid}")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().AsNoTracking()
|
||||
.Where(x => x.Id == id)
|
||||
.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,
|
||||
CollegeName = x.TeachingTask.Course.College!.Name,
|
||||
x.TeachingTask.Course.Credits,
|
||||
TeacherNames = x.TeachingTask.Teachers
|
||||
.OrderByDescending(item => item.IsPrimary)
|
||||
.Select(item => item.Teacher!.Name),
|
||||
ClassNames = x.TeachingTask.Classes
|
||||
.Select(item => item.AdministrativeClass!.Name),
|
||||
x.RegularWeight,
|
||||
x.MidtermWeight,
|
||||
x.FinalWeight,
|
||||
x.Status,
|
||||
x.ReviewComment,
|
||||
x.SubmittedAt,
|
||||
x.ReviewedAt,
|
||||
x.PublishedAt,
|
||||
Records = x.Records
|
||||
.OrderBy(record => record.Student!.StudentNumber)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.StudentId,
|
||||
record.Student!.StudentNumber,
|
||||
record.Student.Name,
|
||||
ClassName = record.Student.AdministrativeClass!.Name,
|
||||
record.RegularScore,
|
||||
record.MidtermScore,
|
||||
record.FinalScore,
|
||||
record.TotalScore,
|
||||
record.GradePoint,
|
||||
record.ExamStatus,
|
||||
record.Notes,
|
||||
record.UpdatedAt
|
||||
}),
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
|
||||
var task = await db.TeachingTasks.AsNoTracking()
|
||||
.Include(x => x.Teachers)
|
||||
.ThenInclude(x => x.Teacher)
|
||||
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
Sheet = sheet,
|
||||
CanEdit = CanEditScores(task) &&
|
||||
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
|
||||
CanReview = IsReviewer() && sheet.Status == GradeSheetStatus.Submitted,
|
||||
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("sheets/{id:guid}/weights")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> UpdateWeights(
|
||||
Guid id,
|
||||
GradeWeightsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GradeCalculator.AreWeightsValid(
|
||||
request.RegularWeight,
|
||||
request.MidtermWeight,
|
||||
request.FinalWeight))
|
||||
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
|
||||
var sheet = await EditableSheetAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||||
return ConflictProblem("当前状态不能修改成绩构成。");
|
||||
sheet.RegularWeight = request.RegularWeight;
|
||||
sheet.MidtermWeight = request.MidtermWeight;
|
||||
sheet.FinalWeight = request.FinalWeight;
|
||||
foreach (var record in sheet.Records) Recalculate(sheet, record);
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("sheets/{id:guid}/records")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> UpdateRecords(
|
||||
Guid id,
|
||||
GradeRecordsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await EditableSheetAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||||
return ConflictProblem("成绩单提交后不能继续修改。");
|
||||
var records = sheet.Records.ToDictionary(x => x.Id);
|
||||
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
|
||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||
return ValidationProblem("包含无效或重复的成绩记录。");
|
||||
|
||||
foreach (var item in request.Records)
|
||||
{
|
||||
if (!ValidScore(item.RegularScore) ||
|
||||
!ValidScore(item.MidtermScore) ||
|
||||
!ValidScore(item.FinalScore))
|
||||
return ValidationProblem("成绩必须在 0—100 分之间。");
|
||||
var record = records[item.Id];
|
||||
record.RegularScore = item.RegularScore;
|
||||
record.MidtermScore = item.MidtermScore;
|
||||
record.FinalScore = item.FinalScore;
|
||||
record.ExamStatus = item.ExamStatus;
|
||||
record.Notes = Normalize(item.Notes);
|
||||
Recalculate(sheet, record);
|
||||
}
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/submit")]
|
||||
[Authorize(Roles = SheetUsers)]
|
||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await EditableSheetAsync(id, cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
|
||||
return ConflictProblem("只有草稿或已退回成绩单可以提交。");
|
||||
if (sheet.Records.Count == 0)
|
||||
return ConflictProblem("成绩单没有学生记录。");
|
||||
if (sheet.Records.Any(x =>
|
||||
x.ExamStatus == GradeExamStatus.Normal &&
|
||||
!x.TotalScore.HasValue))
|
||||
return ConflictProblem("仍有学生缺少必填成绩分项,请补充后再提交。");
|
||||
sheet.Status = GradeSheetStatus.Submitted;
|
||||
sheet.SubmittedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = null;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/approve")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!IsReviewer()) return Forbid();
|
||||
if (sheet.Status != GradeSheetStatus.Submitted)
|
||||
return ConflictProblem("只有待审核成绩单可以通过审核。");
|
||||
sheet.Status = GradeSheetStatus.Approved;
|
||||
sheet.ReviewedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = null;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/return")]
|
||||
[Authorize(Roles = Reviewers)]
|
||||
public async Task<ActionResult> Return(
|
||||
Guid id,
|
||||
GradeReviewRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!IsReviewer()) return Forbid();
|
||||
if (sheet.Status != GradeSheetStatus.Submitted)
|
||||
return ConflictProblem("只有待审核成绩单可以退回。");
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return ValidationProblem("退回时必须填写修改意见。");
|
||||
sheet.Status = GradeSheetStatus.Returned;
|
||||
sheet.ReviewedAt = DateTime.UtcNow;
|
||||
sheet.ReviewComment = request.Comment.Trim();
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("sheets/{id:guid}/publish")]
|
||||
[Authorize(Roles = Publishers)]
|
||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
|
||||
x => x.Id == id,
|
||||
cancellationToken);
|
||||
if (sheet is null) return NotFound();
|
||||
if (!IsPublisher()) return Forbid();
|
||||
if (sheet.Status != GradeSheetStatus.Approved)
|
||||
return ConflictProblem("只有审核通过的成绩单可以发布。");
|
||||
sheet.Status = GradeSheetStatus.Published;
|
||||
sheet.PublishedAt = DateTime.UtcNow;
|
||||
return await SaveAsync(id, false, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("student/transcript")]
|
||||
[Authorize(Roles = SystemRoles.Student)]
|
||||
public async Task<ActionResult> GetStudentTranscript(
|
||||
Guid? academicTermId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = currentUserDataScope.Current.UserId;
|
||||
var student = await db.Students.AsNoTracking()
|
||||
.Where(x => x.UserId == userId)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
x.StudentNumber,
|
||||
x.Name,
|
||||
ClassName = x.AdministrativeClass!.Name,
|
||||
MajorName = x.AdministrativeClass.Major!.Name
|
||||
})
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (student is null)
|
||||
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||
|
||||
var source = db.GradeRecords.AsNoTracking()
|
||||
.Where(x =>
|
||||
x.StudentId == student.Id &&
|
||||
x.GradeSheet!.Status == GradeSheetStatus.Published);
|
||||
if (academicTermId.HasValue)
|
||||
source = source.Where(x =>
|
||||
x.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId);
|
||||
var records = await source
|
||||
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
|
||||
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
|
||||
.Select(x => new
|
||||
{
|
||||
x.Id,
|
||||
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
|
||||
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
|
||||
x.GradeSheet.TeachingTaskId,
|
||||
x.GradeSheet.TeachingTask.TaskNumber,
|
||||
CourseCode = x.GradeSheet.TeachingTask.Course!.Code,
|
||||
CourseName = x.GradeSheet.TeachingTask.Course.Name,
|
||||
x.GradeSheet.TeachingTask.Course.Credits,
|
||||
x.TotalScore,
|
||||
x.GradePoint,
|
||||
x.ExamStatus,
|
||||
x.GradeSheet.PublishedAt
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
return Ok(new { Student = student, Records = records });
|
||||
}
|
||||
|
||||
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 IQueryable<GradeSheet> AccessibleSheets() =>
|
||||
db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
|
||||
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) =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||
IsAssignedTeacher(task);
|
||||
|
||||
private bool CanEditScores(TeachingTask task) =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
IsAssignedTeacher(task);
|
||||
|
||||
private bool IsAssignedTeacher(TeachingTask task) =>
|
||||
task.Teachers.Any(x =>
|
||||
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||
|
||||
private bool IsReviewer() =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin);
|
||||
|
||||
private bool IsPublisher() =>
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||
|
||||
private static void Recalculate(GradeSheet sheet, GradeRecord record)
|
||||
{
|
||||
record.TotalScore = GradeCalculator.CalculateTotal(
|
||||
record.RegularScore,
|
||||
record.MidtermScore,
|
||||
record.FinalScore,
|
||||
sheet.RegularWeight,
|
||||
sheet.MidtermWeight,
|
||||
sheet.FinalWeight,
|
||||
record.ExamStatus);
|
||||
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
|
||||
}
|
||||
|
||||
private static bool ValidScore(decimal? score) =>
|
||||
!score.HasValue || score.Value is >= 0 and <= 100;
|
||||
|
||||
private async Task<ActionResult> SaveAsync(
|
||||
Guid id,
|
||||
bool created,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return created ? Created(string.Empty, new { id }) : NoContent();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return ConflictProblem("成绩单已存在,或关联学生与教学班已发生变化。");
|
||||
}
|
||||
}
|
||||
|
||||
private ActionResult ConflictProblem(string detail) =>
|
||||
Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "无法完成成绩操作",
|
||||
Detail = detail,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public sealed record GradeSheetRequest(
|
||||
Guid TeachingTaskId,
|
||||
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
|
||||
|
||||
public sealed record GradeWeightsRequest(
|
||||
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
|
||||
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
|
||||
|
||||
public sealed record GradeRecordsRequest(
|
||||
IReadOnlyCollection<GradeRecordRequest> Records);
|
||||
|
||||
public sealed record GradeRecordRequest(
|
||||
Guid Id,
|
||||
decimal? RegularScore,
|
||||
decimal? MidtermScore,
|
||||
decimal? FinalScore,
|
||||
GradeExamStatus ExamStatus,
|
||||
[MaxLength(300)] string? Notes);
|
||||
|
||||
public sealed record GradeReviewRequest(
|
||||
[MaxLength(500)] string? Comment);
|
||||
@@ -0,0 +1,50 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.Academic;
|
||||
|
||||
public sealed class GradeSheet : EntityBase
|
||||
{
|
||||
public Guid TeachingTaskId { get; set; }
|
||||
public TeachingTask? TeachingTask { get; set; }
|
||||
public decimal RegularWeight { get; set; } = 30;
|
||||
public decimal MidtermWeight { get; set; }
|
||||
public decimal FinalWeight { get; set; } = 70;
|
||||
public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft;
|
||||
public string? ReviewComment { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
public ICollection<GradeRecord> Records { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class GradeRecord : EntityBase
|
||||
{
|
||||
public Guid GradeSheetId { get; set; }
|
||||
public GradeSheet? GradeSheet { get; set; }
|
||||
public Guid StudentId { get; set; }
|
||||
public Student? Student { get; set; }
|
||||
public decimal? RegularScore { get; set; }
|
||||
public decimal? MidtermScore { get; set; }
|
||||
public decimal? FinalScore { get; set; }
|
||||
public decimal? TotalScore { get; set; }
|
||||
public decimal? GradePoint { get; set; }
|
||||
public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal;
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public enum GradeSheetStatus
|
||||
{
|
||||
Draft = 1,
|
||||
Submitted = 2,
|
||||
Approved = 3,
|
||||
Published = 4,
|
||||
Returned = 5
|
||||
}
|
||||
|
||||
public enum GradeExamStatus
|
||||
{
|
||||
Normal = 1,
|
||||
Absent = 2,
|
||||
Deferred = 3,
|
||||
Exempt = 4
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
public static class GradeCalculator
|
||||
{
|
||||
public static bool AreWeightsValid(
|
||||
decimal regularWeight,
|
||||
decimal midtermWeight,
|
||||
decimal finalWeight) =>
|
||||
regularWeight is >= 0 and <= 100 &&
|
||||
midtermWeight is >= 0 and <= 100 &&
|
||||
finalWeight is >= 0 and <= 100 &&
|
||||
regularWeight + midtermWeight + finalWeight == 100;
|
||||
|
||||
public static decimal? CalculateTotal(
|
||||
decimal? regularScore,
|
||||
decimal? midtermScore,
|
||||
decimal? finalScore,
|
||||
decimal regularWeight,
|
||||
decimal midtermWeight,
|
||||
decimal finalWeight,
|
||||
GradeExamStatus examStatus)
|
||||
{
|
||||
if (examStatus != GradeExamStatus.Normal ||
|
||||
regularWeight > 0 && !regularScore.HasValue ||
|
||||
midtermWeight > 0 && !midtermScore.HasValue ||
|
||||
finalWeight > 0 && !finalScore.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var total =
|
||||
(regularScore ?? 0) * regularWeight / 100 +
|
||||
(midtermScore ?? 0) * midtermWeight / 100 +
|
||||
(finalScore ?? 0) * finalWeight / 100;
|
||||
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static decimal? CalculateGradePoint(decimal? totalScore)
|
||||
{
|
||||
if (!totalScore.HasValue) return null;
|
||||
return totalScore.Value switch
|
||||
{
|
||||
>= 90 => 4.0m,
|
||||
>= 85 => 3.7m,
|
||||
>= 82 => 3.3m,
|
||||
>= 78 => 3.0m,
|
||||
>= 75 => 2.7m,
|
||||
>= 72 => 2.3m,
|
||||
>= 68 => 2.0m,
|
||||
>= 64 => 1.5m,
|
||||
>= 60 => 1.0m,
|
||||
_ => 0m
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
|
||||
Set<CourseSelectionOffering>();
|
||||
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
|
||||
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
|
||||
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
@@ -317,6 +319,40 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<GradeSheet>(entity =>
|
||||
{
|
||||
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.ReviewComment).HasMaxLength(500);
|
||||
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
|
||||
entity.HasIndex(x => x.Status);
|
||||
entity.HasOne(x => x.TeachingTask)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.TeachingTaskId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<GradeRecord>(entity =>
|
||||
{
|
||||
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.TotalScore).HasPrecision(5, 1);
|
||||
entity.Property(x => x.GradePoint).HasPrecision(3, 1);
|
||||
entity.Property(x => x.Notes).HasMaxLength(300);
|
||||
entity.HasIndex(x => new { x.GradeSheetId, x.StudentId }).IsUnique();
|
||||
entity.HasIndex(x => new { x.StudentId, x.TotalScore });
|
||||
entity.HasOne(x => x.GradeSheet)
|
||||
.WithMany(x => x.Records)
|
||||
.HasForeignKey(x => x.GradeSheetId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(x => x.Student)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.StudentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
builder.Entity<AuditLog>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Method).HasMaxLength(10);
|
||||
|
||||
@@ -422,15 +422,13 @@ public sealed class DatabaseInitializer(
|
||||
|
||||
await SeedDevelopmentUsersAsync(computerCollege.Id);
|
||||
await SeedDevelopmentCourseSelectionAsync();
|
||||
await SeedDevelopmentGradesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentCourseSelectionAsync()
|
||||
{
|
||||
if (await db.CourseSelectionRounds.AnyAsync())
|
||||
if (!await db.CourseSelectionRounds.AnyAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
|
||||
var task = await db.TeachingTasks.SingleAsync(
|
||||
x => x.TaskNumber == "2026-1-CS101-01");
|
||||
@@ -459,6 +457,55 @@ public sealed class DatabaseInitializer(
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var seededTask = await db.TeachingTasks.SingleAsync(
|
||||
x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var offering = await db.CourseSelectionOfferings.FirstOrDefaultAsync(
|
||||
x => x.TeachingTaskId == seededTask.Id);
|
||||
var student = await db.Students.SingleAsync(x =>
|
||||
x.StudentNumber == "202601001");
|
||||
if (offering is not null &&
|
||||
!await db.CourseEnrollments.AnyAsync(x =>
|
||||
x.CourseSelectionOfferingId == offering.Id &&
|
||||
x.StudentId == student.Id))
|
||||
{
|
||||
db.CourseEnrollments.Add(new CourseEnrollment
|
||||
{
|
||||
CourseSelectionOfferingId = offering.Id,
|
||||
StudentId = student.Id
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentGradesAsync()
|
||||
{
|
||||
if (await db.GradeSheets.AnyAsync()) return;
|
||||
var task = await db.TeachingTasks.SingleAsync(
|
||||
x => x.TaskNumber == "2026-1-CS101-01");
|
||||
var student = await db.Students.SingleAsync(
|
||||
x => x.StudentNumber == "202601001");
|
||||
db.GradeSheets.Add(new GradeSheet
|
||||
{
|
||||
TeachingTaskId = task.Id,
|
||||
RegularWeight = 30,
|
||||
MidtermWeight = 0,
|
||||
FinalWeight = 70,
|
||||
Status = GradeSheetStatus.Draft,
|
||||
Records =
|
||||
[
|
||||
new GradeRecord
|
||||
{
|
||||
StudentId = student.Id,
|
||||
RegularScore = 88,
|
||||
FinalScore = 92,
|
||||
TotalScore = 90.8m,
|
||||
GradePoint = 4.0m
|
||||
}
|
||||
]
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SeedDevelopmentUsersAsync(Guid collegeId)
|
||||
{
|
||||
var definitions = new[]
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
private const string SchedulesMigration = "20260724_04_schedules";
|
||||
private const string ClassCounselorMigration = "20260724_05_class_counselor";
|
||||
private const string CourseSelectionMigration = "20260724_06_course_selection";
|
||||
private const string GradesMigration = "20260724_07_grades";
|
||||
|
||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -63,6 +64,10 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
CourseSelectionMigration,
|
||||
CourseSelectionStatements,
|
||||
cancellationToken);
|
||||
await ApplyMigrationAsync(
|
||||
GradesMigration,
|
||||
GradesStatements,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
@@ -506,4 +511,62 @@ public sealed class DevelopmentSqliteMigrator(
|
||||
ON "CourseEnrollments" ("StudentId", "Status");
|
||||
"""
|
||||
];
|
||||
|
||||
private static readonly string[] GradesStatements =
|
||||
[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "GradeSheets" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY,
|
||||
"TeachingTaskId" TEXT NOT NULL,
|
||||
"RegularWeight" TEXT NOT NULL,
|
||||
"MidtermWeight" 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
|
||||
);
|
||||
""",
|
||||
"""
|
||||
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 IF NOT EXISTS "GradeRecords" (
|
||||
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeRecords" PRIMARY KEY,
|
||||
"GradeSheetId" TEXT NOT NULL,
|
||||
"StudentId" TEXT NOT NULL,
|
||||
"RegularScore" TEXT NULL,
|
||||
"MidtermScore" 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
|
||||
);
|
||||
""",
|
||||
"""
|
||||
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");
|
||||
"""
|
||||
];
|
||||
}
|
||||
|
||||
+1807
File diff suppressed because it is too large
Load Diff
+111
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class GradeManagement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GradeSheets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
RegularWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
MidtermWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
FinalWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||
PublishedAt = 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_GradeSheets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_GradeSheets_TeachingTasks_TeachingTaskId",
|
||||
column: x => x.TeachingTaskId,
|
||||
principalTable: "TeachingTasks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GradeRecords",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||
RegularScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
MidtermScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
FinalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||
GradePoint = table.Column<decimal>(type: "decimal(3,1)", precision: 3, scale: 1, nullable: true),
|
||||
ExamStatus = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GradeRecords", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_GradeRecords_GradeSheets_GradeSheetId",
|
||||
column: x => x.GradeSheetId,
|
||||
principalTable: "GradeSheets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_GradeRecords_Students_StudentId",
|
||||
column: x => x.StudentId,
|
||||
principalTable: "Students",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
})
|
||||
.Annotation("MySQL:Charset", "utf8mb4");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GradeRecords_GradeSheetId_StudentId",
|
||||
table: "GradeRecords",
|
||||
columns: new[] { "GradeSheetId", "StudentId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GradeRecords_StudentId_TotalScore",
|
||||
table: "GradeRecords",
|
||||
columns: new[] { "StudentId", "TotalScore" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GradeSheets_Status",
|
||||
table: "GradeSheets",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GradeSheets_TeachingTaskId",
|
||||
table: "GradeSheets",
|
||||
column: "TeachingTaskId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "GradeRecords");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "GradeSheets");
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -634,6 +634,114 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.ToTable("CurriculumPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("ExamStatus")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("FinalScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal?>("GradePoint")
|
||||
.HasPrecision(3, 1)
|
||||
.HasColumnType("decimal(3,1)");
|
||||
|
||||
b.Property<Guid>("GradeSheetId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal?>("MidtermScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("varchar(300)");
|
||||
|
||||
b.Property<decimal?>("RegularScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<Guid>("StudentId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<decimal?>("TotalScore")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GradeSheetId", "StudentId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("StudentId", "TotalScore");
|
||||
|
||||
b.ToTable("GradeRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("FinalWeight")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<decimal>("MidtermWeight")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<DateTime?>("PublishedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<decimal>("RegularWeight")
|
||||
.HasPrecision(5, 1)
|
||||
.HasColumnType("decimal(5,1)");
|
||||
|
||||
b.Property<string>("ReviewComment")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("varchar(500)");
|
||||
|
||||
b.Property<DateTime?>("ReviewedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("SubmittedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<Guid>("TeachingTaskId")
|
||||
.HasColumnType("char(36)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("TeachingTaskId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("GradeSheets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1430,6 +1538,36 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Major");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
|
||||
.WithMany("Records")
|
||||
.HasForeignKey("GradeSheetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
|
||||
.WithMany()
|
||||
.HasForeignKey("StudentId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("GradeSheet");
|
||||
|
||||
b.Navigation("Student");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
|
||||
.WithMany()
|
||||
.HasForeignKey("TeachingTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("TeachingTask");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
|
||||
{
|
||||
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
|
||||
@@ -1644,6 +1782,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||
b.Navigation("Modules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
|
||||
{
|
||||
b.Navigation("Records");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Jiaowu.Api.Domain.Academic;
|
||||
using Jiaowu.Api.Infrastructure.Grades;
|
||||
|
||||
namespace Jiaowu.Api.Tests;
|
||||
|
||||
public sealed class GradeCalculatorTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(30, 0, 70, true)]
|
||||
[InlineData(20, 20, 60, true)]
|
||||
[InlineData(30, 20, 40, false)]
|
||||
[InlineData(-10, 40, 70, false)]
|
||||
public void Weights_must_be_non_negative_and_total_one_hundred(
|
||||
decimal regular,
|
||||
decimal midterm,
|
||||
decimal final,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
GradeCalculator.AreWeightsValid(regular, midterm, final));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Total_uses_configured_weights_and_one_decimal_rounding()
|
||||
{
|
||||
var total = GradeCalculator.CalculateTotal(
|
||||
83,
|
||||
null,
|
||||
91,
|
||||
30,
|
||||
0,
|
||||
70,
|
||||
GradeExamStatus.Normal);
|
||||
|
||||
Assert.Equal(88.6m, total);
|
||||
Assert.Equal(3.7m, GradeCalculator.CalculateGradePoint(total));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_required_component_keeps_total_unresolved()
|
||||
{
|
||||
Assert.Null(GradeCalculator.CalculateTotal(
|
||||
83,
|
||||
null,
|
||||
null,
|
||||
30,
|
||||
0,
|
||||
70,
|
||||
GradeExamStatus.Normal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(GradeExamStatus.Absent)]
|
||||
[InlineData(GradeExamStatus.Deferred)]
|
||||
[InlineData(GradeExamStatus.Exempt)]
|
||||
public void Exceptional_exam_status_has_no_numeric_total(GradeExamStatus status)
|
||||
{
|
||||
Assert.Null(GradeCalculator.CalculateTotal(90, null, 90, 30, 0, 70, status));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Tickets,
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
DocumentChecked,
|
||||
User,
|
||||
UserFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
@@ -41,6 +42,7 @@ const pageTitle = computed(() => {
|
||||
'teaching-tasks': '教学任务',
|
||||
schedules: '排课与课表',
|
||||
'course-selections': '选课与教学班',
|
||||
grades: '成绩与学业档案',
|
||||
users: '用户与权限',
|
||||
}
|
||||
return titles[String(route.name)] ?? '教务管理'
|
||||
@@ -129,6 +131,13 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<template #title>{{ auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '选课管理' : '学生选课' }}</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item
|
||||
v-if="auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'].includes(role))"
|
||||
index="/grades"
|
||||
>
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
<template #title>{{ auth.user?.roles.includes('Student') ? '学业成绩' : '成绩管理' }}</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="auth.isSuperAdmin" index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>用户与权限</template>
|
||||
@@ -137,7 +146,7 @@ onMounted(() => auth.refresh().catch(() => undefined))
|
||||
|
||||
<div v-if="!collapsed" class="phase-note">
|
||||
<span>第一阶段 · 核心可用版</span>
|
||||
<p>教学运行、排课与学生选课已就绪</p>
|
||||
<p>教学运行、选课与成绩管理已就绪</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ const router = createRouter({
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'grades',
|
||||
name: 'grades',
|
||||
component: () => import('../views/GradesView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
|
||||
@@ -378,6 +378,138 @@ button { cursor: pointer; }
|
||||
.roster-summary b { margin-top: 5px; font-size: 14px; }
|
||||
.roster-summary small { margin-top: 4px; color: #bec8e2; font-size: 10px; }
|
||||
|
||||
.grade-page { min-width: 0; }
|
||||
.grade-toolbar {
|
||||
min-height: 68px; padding: 13px 16px; display: flex; align-items: center; gap: 10px;
|
||||
border: 1px solid var(--line); background: white;
|
||||
}
|
||||
.grade-toolbar .el-select { width: 210px; }
|
||||
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 10px; }
|
||||
.grade-workspace {
|
||||
min-height: 650px; display: grid; grid-template-columns: 285px minmax(0, 1fr);
|
||||
border: 1px solid var(--line); background: white;
|
||||
}
|
||||
.grade-sheet-list {
|
||||
min-width: 0; border-right: 1px solid var(--line); background: #fafbfc;
|
||||
}
|
||||
.grade-sheet-list > button {
|
||||
width: 100%; min-height: 100px; padding: 16px 17px; display: grid;
|
||||
grid-template-columns: 1fr auto; gap: 6px 10px; text-align: left;
|
||||
border: none; border-bottom: 1px solid var(--line); border-left: 3px solid transparent;
|
||||
color: var(--ink); background: transparent;
|
||||
}
|
||||
.grade-sheet-list > button:hover { background: white; }
|
||||
.grade-sheet-list > button.active { border-left-color: var(--teal); background: white; }
|
||||
.grade-sheet-list span, .grade-sheet-list small {
|
||||
overflow: hidden; color: var(--muted); font-size: 9px; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.grade-sheet-list b {
|
||||
grid-column: 1 / -1; overflow: hidden; font-family: "STZhongsong", "Songti SC", serif;
|
||||
font-size: 14px; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.grade-sheet-list i {
|
||||
padding: 3px 7px; border-radius: 10px; color: #777f8c;
|
||||
background: #eceff3; font-size: 9px; font-style: normal;
|
||||
}
|
||||
.grade-sheet-list i.submitted { color: #a66b16; background: #fbefd9; }
|
||||
.grade-sheet-list i.approved, .grade-sheet-list i.published { color: var(--teal); background: #e4f4f0; }
|
||||
.grade-sheet-list i.returned { color: #b05252; background: #f8e7e7; }
|
||||
.grade-register { min-width: 0; }
|
||||
.grade-create-empty {
|
||||
min-height: 620px; display: grid; place-content: center; justify-items: center;
|
||||
padding: 30px; text-align: center;
|
||||
}
|
||||
.grade-create-empty > .el-icon { font-size: 50px; color: #98a5be; }
|
||||
.grade-create-empty h3 { margin: 18px 0 8px; font-family: "STZhongsong", "Songti SC", serif; font-size: 23px; }
|
||||
.grade-create-empty p { max-width: 440px; margin: 0 0 20px; color: var(--muted); font-size: 11px; line-height: 1.7; }
|
||||
.grade-register-head {
|
||||
min-height: 108px; padding: 21px 24px; display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.grade-register-head span { color: var(--teal); font: 700 9px/1 Consolas, monospace; letter-spacing: .1em; }
|
||||
.grade-register-head h3 { margin: 8px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 22px; }
|
||||
.grade-register-head p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.grade-register-head > i {
|
||||
padding: 7px 12px; border-radius: 15px; color: #657083; background: #edf0f4;
|
||||
font-size: 10px; font-style: normal;
|
||||
}
|
||||
.grade-register-head > i.submitted { color: #a66b16; background: #fbefd9; }
|
||||
.grade-register-head > i.approved, .grade-register-head > i.published { color: var(--teal); background: #e4f4f0; }
|
||||
.grade-register-head > i.returned { color: #ad4e4e; background: #f7e5e5; }
|
||||
.grade-register-ruler {
|
||||
min-height: 92px; display: grid; grid-template-columns: repeat(4, 120px) 1fr;
|
||||
color: white; background:
|
||||
repeating-linear-gradient(90deg, transparent 0, transparent 23px, rgba(255,255,255,.07) 24px),
|
||||
linear-gradient(105deg, #17295a, #263f80);
|
||||
}
|
||||
.grade-register-ruler > div { padding: 17px 19px; border-right: 1px solid rgba(255,255,255,.14); }
|
||||
.grade-register-ruler span, .grade-register-ruler b { display: block; }
|
||||
.grade-register-ruler span { color: #b6c0dc; font-size: 9px; }
|
||||
.grade-register-ruler b { margin-top: 7px; color: #5bd4c3; font: 700 24px/1 Consolas, monospace; }
|
||||
.grade-register-ruler p { margin: 0; padding: 20px 22px; align-self: center; color: #cbd3e8; font-size: 10px; text-align: right; }
|
||||
.grade-register > .el-alert { border-radius: 0; }
|
||||
.grade-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.grade-entry-table { min-width: 800px; }
|
||||
.grade-entry-table .el-input-number { width: 92px; }
|
||||
.grade-entry-table .el-input-number .el-input__inner { text-align: center; }
|
||||
.total-score { color: var(--indigo); font: 700 15px/1 Consolas, monospace; }
|
||||
.total-score.excellent { color: var(--teal); }
|
||||
.total-score.failed { color: #b44c4c; }
|
||||
.total-score.pending { color: #a0a6b0; }
|
||||
.grade-actions {
|
||||
min-height: 78px; padding: 14px 20px; display: flex; align-items: center;
|
||||
justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); background: #fafbfc;
|
||||
}
|
||||
.grade-actions > div { margin-right: auto; }
|
||||
.grade-actions span, .grade-actions b { display: block; }
|
||||
.grade-actions span { color: var(--muted); font-size: 9px; }
|
||||
.grade-actions b { margin-top: 4px; font-size: 13px; }
|
||||
.grade-actions .el-button + .el-button { margin-left: 0; }
|
||||
.grade-weight-form { margin-top: 18px; }
|
||||
.transcript-identity {
|
||||
min-height: 135px; padding: 24px 28px; display: flex; align-items: center;
|
||||
justify-content: space-between; gap: 25px; color: white;
|
||||
background:
|
||||
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px),
|
||||
linear-gradient(112deg, #162858, #243d7d 72%, #176b70);
|
||||
background-size: 28px 28px, 28px 28px, auto;
|
||||
}
|
||||
.transcript-identity span { color: #5bd4c3; font: 700 9px/1 Consolas, monospace; letter-spacing: .15em; }
|
||||
.transcript-identity h3 { margin: 9px 0 6px; font-family: "STZhongsong", "Songti SC", serif; font-size: 25px; }
|
||||
.transcript-identity p { margin: 0; color: #c2cce5; font-size: 10px; }
|
||||
.transcript-identity label { width: 265px; }
|
||||
.transcript-identity label > span { display: block; margin-bottom: 7px; }
|
||||
.transcript-identity .el-select { width: 100%; }
|
||||
.transcript-ribbon { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); background: white; }
|
||||
.transcript-ribbon > div { min-height: 85px; padding: 17px 22px; display: flex; align-items: baseline; gap: 7px; border-right: 1px solid var(--line); }
|
||||
.transcript-ribbon > div:last-child { border-right: none; }
|
||||
.transcript-ribbon span { color: var(--muted); font-size: 10px; }
|
||||
.transcript-ribbon b { margin-left: auto; color: var(--indigo); font: 700 27px/1 Consolas, monospace; }
|
||||
.transcript-ribbon small { color: var(--muted); font-size: 9px; }
|
||||
.transcript-list { display: grid; gap: 10px; }
|
||||
.transcript-list > article {
|
||||
min-height: 114px; padding: 18px 21px; display: grid;
|
||||
grid-template-columns: minmax(230px, 1fr) minmax(200px, 1.4fr) 105px;
|
||||
align-items: center; gap: 28px; border: 1px solid var(--line); background: white;
|
||||
}
|
||||
.transcript-course span { color: var(--teal); font-size: 9px; }
|
||||
.transcript-course h3 { margin: 7px 0 5px; font-family: "STZhongsong", "Songti SC", serif; font-size: 18px; }
|
||||
.transcript-course p { margin: 0; color: var(--muted); font-size: 10px; }
|
||||
.grade-scale { height: 22px; display: grid; grid-template-columns: repeat(11, 1fr); position: relative; border-bottom: 2px solid #cfd5de; }
|
||||
.grade-scale > i { border-left: 1px solid #cfd5de; }
|
||||
.grade-scale > span {
|
||||
position: absolute; bottom: -12px; transform: translateX(-50%); padding: 4px 6px;
|
||||
border-radius: 9px; color: white; background: var(--indigo); font: 700 10px Consolas, monospace;
|
||||
}
|
||||
.grade-scale > span.excellent { background: var(--teal); }
|
||||
.grade-scale > span.failed { background: #b44c4c; }
|
||||
.transcript-result { padding-left: 20px; border-left: 1px solid var(--line); text-align: right; }
|
||||
.transcript-result span, .transcript-result b, .transcript-result small { display: block; }
|
||||
.transcript-result span { color: var(--muted); font-size: 9px; }
|
||||
.transcript-result b { margin: 5px 0 3px; color: var(--indigo); font: 700 25px/1 Consolas, monospace; }
|
||||
.transcript-result small { color: var(--muted); font-size: 9px; }
|
||||
|
||||
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(440px, 1.2fr) minmax(420px, .8fr); background: white; }
|
||||
.login-story { min-height: 100vh; padding: 54px clamp(45px, 6vw, 90px); display: flex; flex-direction: column; color: white; background: linear-gradient(142deg, #13224d, #243a77 62%, #176b71); overflow: hidden; position: relative; }
|
||||
.login-story::before { content: ""; position: absolute; inset: 0; opacity: .28; background-image: linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px); background-size: 46px 46px; }
|
||||
@@ -475,6 +607,14 @@ button { cursor: pointer; }
|
||||
}
|
||||
.round-actions { justify-content: flex-start; }
|
||||
.offering-grid { grid-template-columns: 1fr; }
|
||||
.grade-workspace { display: block; }
|
||||
.grade-sheet-list { max-height: 300px; overflow-y: auto; border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.grade-register-ruler { grid-template-columns: repeat(4, 1fr); }
|
||||
.grade-register-ruler > div { padding: 14px; }
|
||||
.grade-register-ruler p { grid-column: 1 / -1; padding: 10px 14px; text-align: left; border-top: 1px solid rgba(255,255,255,.14); }
|
||||
.transcript-list > article { grid-template-columns: 1fr 120px; }
|
||||
.grade-scale { grid-column: 1 / -1; grid-row: 2; }
|
||||
.transcript-result { grid-column: 2; grid-row: 1; }
|
||||
.role-editor-summary { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.form-grid, .form-grid.three { grid-template-columns: 1fr; gap: 0; }
|
||||
.el-dialog { width: calc(100vw - 24px) !important; }
|
||||
@@ -498,6 +638,22 @@ button { cursor: pointer; }
|
||||
.selection-ledger-head { align-items: flex-start; flex-direction: column; }
|
||||
.offering-ticket > footer { align-items: stretch; flex-direction: column; }
|
||||
.offering-ticket > footer .el-button { width: 100%; }
|
||||
.grade-toolbar { flex-wrap: wrap; }
|
||||
.grade-toolbar .el-select { flex: 1 1 140px; width: auto; }
|
||||
.grade-toolbar > span { width: 100%; margin-left: 0; }
|
||||
.grade-register-head { align-items: flex-start; }
|
||||
.grade-register-ruler { grid-template-columns: 1fr 1fr; }
|
||||
.grade-register-ruler > div:nth-child(2) { border-right: none; }
|
||||
.grade-actions { align-items: stretch; flex-direction: column; }
|
||||
.grade-actions > div { margin: 0 0 5px; }
|
||||
.grade-actions .el-button { width: 100%; }
|
||||
.transcript-identity { align-items: stretch; flex-direction: column; }
|
||||
.transcript-identity label { width: 100%; }
|
||||
.transcript-ribbon { grid-template-columns: 1fr; }
|
||||
.transcript-ribbon > div { min-height: 65px; border-right: none; border-bottom: 1px solid var(--line); }
|
||||
.transcript-list > article { grid-template-columns: 1fr; gap: 17px; }
|
||||
.transcript-result { grid-column: auto; grid-row: auto; padding: 12px 0 0; border-left: none; border-top: 1px solid var(--line); text-align: left; }
|
||||
.grade-scale { grid-column: auto; grid-row: auto; }
|
||||
.el-drawer { width: 100% !important; }
|
||||
}
|
||||
|
||||
|
||||
@@ -112,19 +112,25 @@ onMounted(async () => {
|
||||
<b>{{ data.counts.courseSelectionRounds ?? 0 }} 个批次 · {{ data.counts.courseEnrollments ?? 0 }} 条有效选课</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
<div>
|
||||
<span>成绩管理</span>
|
||||
<b>{{ data.counts.gradeSheets ?? 0 }} 张登记册 · {{ data.counts.publishedGradeSheets ?? 0 }} 张已发布</b>
|
||||
<i class="done">已建立</i>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="work-card phase-card">
|
||||
<span class="section-kicker">NEXT MILESTONE</span>
|
||||
<h3>下一段业务链</h3>
|
||||
<p>选课批次、容量校验和教学班名单已就绪,下一步进入成绩录入、审核与学业档案。</p>
|
||||
<p>成绩录入、审核、发布与学生成绩单已就绪,下一步进入考试安排与考场管理。</p>
|
||||
<div class="phase-line">
|
||||
<span class="active">基础底座</span>
|
||||
<span class="active">人员档案</span>
|
||||
<span class="active">培养方案</span>
|
||||
<span class="active">排课课表</span>
|
||||
<span class="active">学生选课</span>
|
||||
<span class="active">成绩管理</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
Check,
|
||||
DocumentChecked,
|
||||
EditPen,
|
||||
Plus,
|
||||
Promotion,
|
||||
Refresh,
|
||||
} from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const isStudent = computed(() => auth.user?.roles.includes('Student') &&
|
||||
!auth.user.roles.some((role) =>
|
||||
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher'].includes(role)))
|
||||
const terms = ref<any[]>([])
|
||||
const tasks = ref<any[]>([])
|
||||
const selectedTask = ref<any | null>(null)
|
||||
const detail = ref<any | null>(null)
|
||||
const transcript = ref<any>({ student: null, records: [] })
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const createDialog = ref(false)
|
||||
const returnDialog = ref(false)
|
||||
const termId = ref<string | undefined>()
|
||||
const status = ref<string | undefined>()
|
||||
const createForm = reactive({
|
||||
regularWeight: 30,
|
||||
midtermWeight: 0,
|
||||
finalWeight: 70,
|
||||
})
|
||||
const returnComment = ref('')
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
Draft: '录入中',
|
||||
Submitted: '待审核',
|
||||
Approved: '审核通过',
|
||||
Published: '已发布',
|
||||
Returned: '已退回',
|
||||
}
|
||||
const examStatusLabels: Record<string, string> = {
|
||||
Normal: '正常',
|
||||
Absent: '缺考',
|
||||
Deferred: '缓考',
|
||||
Exempt: '免修',
|
||||
}
|
||||
const completedCount = computed(() =>
|
||||
detail.value?.records.filter((record: any) =>
|
||||
record.totalScore != null || record.examStatus !== 'Normal').length ?? 0)
|
||||
const averageScore = computed(() => {
|
||||
const scores = (detail.value?.records ?? [])
|
||||
.map((record: any) => Number(record.totalScore))
|
||||
.filter((value: number) => Number.isFinite(value))
|
||||
if (!scores.length) return '—'
|
||||
return (scores.reduce((sum: number, value: number) => sum + value, 0) / scores.length)
|
||||
.toFixed(1)
|
||||
})
|
||||
const passRate = computed(() => {
|
||||
const scores = (detail.value?.records ?? [])
|
||||
.map((record: any) => Number(record.totalScore))
|
||||
.filter((value: number) => Number.isFinite(value))
|
||||
if (!scores.length) return '—'
|
||||
return `${Math.round(scores.filter((value: number) => value >= 60).length / scores.length * 100)}%`
|
||||
})
|
||||
const transcriptGpa = computed(() => {
|
||||
const numeric = transcript.value.records.filter((record: any) => record.gradePoint != null)
|
||||
const credits = numeric.reduce((sum: number, record: any) => sum + Number(record.credits), 0)
|
||||
if (!credits) return '—'
|
||||
const points = numeric.reduce(
|
||||
(sum: number, record: any) => sum + Number(record.gradePoint) * Number(record.credits),
|
||||
0,
|
||||
)
|
||||
return (points / credits).toFixed(2)
|
||||
})
|
||||
const earnedCredits = computed(() =>
|
||||
transcript.value.records
|
||||
.filter((record: any) => Number(record.totalScore) >= 60)
|
||||
.reduce((sum: number, record: any) => sum + Number(record.credits), 0))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (isStudent.value) {
|
||||
transcript.value = (await http.get('/grades/student/transcript', {
|
||||
params: { academicTermId: termId.value },
|
||||
})).data
|
||||
return
|
||||
}
|
||||
tasks.value = (await http.get('/grades/sheets', {
|
||||
params: {
|
||||
academicTermId: termId.value,
|
||||
status: status.value,
|
||||
},
|
||||
})).data
|
||||
const preferred = tasks.value.find((item) => item.id === selectedTask.value?.id)
|
||||
?? tasks.value[0]
|
||||
if (preferred) await selectTask(preferred)
|
||||
else {
|
||||
selectedTask.value = null
|
||||
detail.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTask(task: any) {
|
||||
selectedTask.value = task
|
||||
detail.value = null
|
||||
if (!task.sheet) return
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const response = (await http.get(`/grades/sheets/${task.sheet.id}`)).data
|
||||
detail.value = response.sheet
|
||||
detail.value.canEdit = response.canEdit
|
||||
detail.value.canReview = response.canReview
|
||||
detail.value.canPublish = response.canPublish
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(createForm, {
|
||||
regularWeight: 30,
|
||||
midtermWeight: 0,
|
||||
finalWeight: 70,
|
||||
})
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function createSheet() {
|
||||
if (createForm.regularWeight + createForm.midtermWeight + createForm.finalWeight !== 100) {
|
||||
ElMessage.warning('三个成绩分项的比例必须合计 100%。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post('/grades/sheets', {
|
||||
teachingTaskId: selectedTask.value.id,
|
||||
...createForm,
|
||||
})
|
||||
createDialog.value = false
|
||||
ElMessage.success('成绩登记册已建立')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecords() {
|
||||
try {
|
||||
await http.put(`/grades/sheets/${detail.value.id}/records`, {
|
||||
records: detail.value.records.map((record: any) => ({
|
||||
id: record.id,
|
||||
regularScore: record.regularScore,
|
||||
midtermScore: record.midtermScore,
|
||||
finalScore: record.finalScore,
|
||||
examStatus: record.examStatus,
|
||||
notes: record.notes,
|
||||
})),
|
||||
})
|
||||
ElMessage.success('成绩已保存并重新计算总评')
|
||||
await selectTask(selectedTask.value)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSheet() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提交后成绩将锁定并进入学院审核,确认所有学生成绩已经核对?',
|
||||
'提交成绩',
|
||||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续检查' },
|
||||
)
|
||||
await http.post(`/grades/sheets/${detail.value.id}/submit`)
|
||||
ElMessage.success('成绩单已提交审核')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function approveSheet() {
|
||||
try {
|
||||
await http.post(`/grades/sheets/${detail.value.id}/approve`)
|
||||
ElMessage.success('成绩单已通过审核')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function openReturn() {
|
||||
returnComment.value = ''
|
||||
returnDialog.value = true
|
||||
}
|
||||
|
||||
async function returnSheet() {
|
||||
if (!returnComment.value.trim()) {
|
||||
ElMessage.warning('请填写需要教师修改的具体内容。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.post(`/grades/sheets/${detail.value.id}/return`, {
|
||||
comment: returnComment.value,
|
||||
})
|
||||
returnDialog.value = false
|
||||
ElMessage.success('成绩单已退回教师修改')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function publishSheet() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'发布后学生即可查看成绩,当前版本将锁定。确定发布?',
|
||||
'发布成绩',
|
||||
{ type: 'warning', confirmButtonText: '确认发布', cancelButtonText: '取消' },
|
||||
)
|
||||
await http.post(`/grades/sheets/${detail.value.id}/publish`)
|
||||
ElMessage.success('成绩已向学生发布')
|
||||
await load()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
function scoreClass(score: number | null) {
|
||||
if (score == null) return 'pending'
|
||||
if (score < 60) return 'failed'
|
||||
if (score >= 90) return 'excellent'
|
||||
return ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
terms.value = (await http.get('/base-data/terms')).data
|
||||
termId.value = terms.value.find((item) => item.isCurrent)?.id
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack grade-page">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">ACADEMIC RECORD</span>
|
||||
<h2>{{ isStudent ? '学业成绩单' : '成绩管理' }}</h2>
|
||||
<p v-if="isStudent">查看学校已正式发布的课程成绩、学分与绩点。</p>
|
||||
<p v-else>从教师登记、学院复核到校级发布,保留每张成绩单的明确状态。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</section>
|
||||
|
||||
<template v-if="isStudent">
|
||||
<section class="transcript-identity">
|
||||
<div>
|
||||
<span>STUDENT TRANSCRIPT</span>
|
||||
<h3>{{ transcript.student?.name ?? '学生' }}</h3>
|
||||
<p>{{ transcript.student?.studentNumber }} · {{ transcript.student?.majorName }} · {{ transcript.student?.className }}</p>
|
||||
</div>
|
||||
<label>
|
||||
<span>查看学期</span>
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
</el-select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="transcript-ribbon">
|
||||
<div><span>已发布课程</span><b>{{ transcript.records.length }}</b><small>门</small></div>
|
||||
<div><span>获得学分</span><b>{{ earnedCredits }}</b><small>分</small></div>
|
||||
<div><span>加权平均绩点</span><b>{{ transcriptGpa }}</b><small>GPA</small></div>
|
||||
</section>
|
||||
|
||||
<section class="transcript-list" v-loading="loading">
|
||||
<article v-for="record in transcript.records" :key="record.id">
|
||||
<div class="transcript-course">
|
||||
<span>{{ record.termName }}</span>
|
||||
<h3>{{ record.courseName }}</h3>
|
||||
<p>{{ record.courseCode }} · {{ record.taskNumber }} · {{ record.credits }} 学分</p>
|
||||
</div>
|
||||
<div class="grade-scale">
|
||||
<i v-for="tick in 11" :key="tick" />
|
||||
<span
|
||||
v-if="record.totalScore != null"
|
||||
:class="scoreClass(record.totalScore)"
|
||||
:style="{ left: `${record.totalScore}%` }"
|
||||
>{{ record.totalScore }}</span>
|
||||
</div>
|
||||
<div class="transcript-result">
|
||||
<span>{{ examStatusLabels[record.examStatus] }}</span>
|
||||
<b v-if="record.totalScore != null">{{ record.totalScore }}</b>
|
||||
<b v-else>—</b>
|
||||
<small>绩点 {{ record.gradePoint ?? '—' }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<el-empty v-if="!transcript.records.length" description="当前范围内暂无已发布成绩" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="grade-toolbar">
|
||||
<el-select v-model="termId" clearable placeholder="全部学期" @change="load">
|
||||
<el-option v-for="term in terms" :key="term.id" :label="term.name" :value="term.id" />
|
||||
</el-select>
|
||||
<el-select v-model="status" clearable placeholder="全部状态" @change="load">
|
||||
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
<span>共 {{ tasks.length }} 个可管理教学班</span>
|
||||
</section>
|
||||
|
||||
<section class="grade-workspace" v-loading="loading">
|
||||
<aside class="grade-sheet-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.teacherNames.join('、') }} · {{ task.classNames.join('、') }}</small>
|
||||
<i :class="task.sheet?.status?.toLowerCase()">
|
||||
{{ task.sheet ? statusLabels[task.sheet.status] : '未建册' }}
|
||||
</i>
|
||||
</button>
|
||||
<el-empty v-if="!tasks.length" description="没有可管理的教学班" />
|
||||
</aside>
|
||||
|
||||
<main class="grade-register" v-loading="detailLoading">
|
||||
<el-empty v-if="!selectedTask" description="请选择教学班" />
|
||||
<section v-else-if="!selectedTask.sheet" class="grade-create-empty">
|
||||
<el-icon><DocumentChecked /></el-icon>
|
||||
<h3>{{ selectedTask.courseName }}</h3>
|
||||
<p>{{ selectedTask.taskNumber }} 尚未建立成绩登记册。系统会从有效选课名单生成学生行。</p>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">建立成绩登记册</el-button>
|
||||
</section>
|
||||
<template v-else-if="detail">
|
||||
<header class="grade-register-head">
|
||||
<div>
|
||||
<span>{{ detail.courseCode }} · {{ detail.taskNumber }}</span>
|
||||
<h3>{{ detail.courseName }}成绩登记册</h3>
|
||||
<p>{{ detail.termName }} · {{ detail.teacherNames.join('、') }} · {{ detail.classNames.join('、') }}</p>
|
||||
</div>
|
||||
<i :class="detail.status.toLowerCase()">{{ statusLabels[detail.status] }}</i>
|
||||
</header>
|
||||
|
||||
<section class="grade-register-ruler">
|
||||
<div><span>学生数</span><b>{{ detail.records.length }}</b></div>
|
||||
<div><span>已完成</span><b>{{ completedCount }}</b></div>
|
||||
<div><span>平均分</span><b>{{ averageScore }}</b></div>
|
||||
<div><span>及格率</span><b>{{ passRate }}</b></div>
|
||||
<p>
|
||||
平时 {{ detail.regularWeight }}%
|
||||
<template v-if="detail.midtermWeight"> · 期中 {{ detail.midtermWeight }}%</template>
|
||||
· 期末 {{ detail.finalWeight }}%
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="detail.status === 'Returned'"
|
||||
:title="`审核退回:${detail.reviewComment}`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div class="grade-table-scroll">
|
||||
<el-table :data="detail.records" class="data-table grade-entry-table">
|
||||
<el-table-column label="学生" fixed min-width="165">
|
||||
<template #default="{ row }">
|
||||
<div class="record-name">
|
||||
<b>{{ row.name }}</b>
|
||||
<span>{{ row.studentNumber }} · {{ row.className }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="125">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||||
v-model="row.regularScore"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
/>
|
||||
<span v-else>{{ row.regularScore ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="detail.midtermWeight" :label="`期中 ${detail.midtermWeight}%`" width="125">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||||
v-model="row.midtermScore"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
/>
|
||||
<span v-else>{{ row.midtermScore ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="125">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-if="detail.canEdit && row.examStatus === 'Normal'"
|
||||
v-model="row.finalScore"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:controls="false"
|
||||
/>
|
||||
<span v-else>{{ row.finalScore ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总评" width="90">
|
||||
<template #default="{ row }">
|
||||
<b class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="考试状态" width="125">
|
||||
<template #default="{ row }">
|
||||
<el-select v-if="detail.canEdit" v-model="row.examStatus">
|
||||
<el-option v-for="(label, value) in examStatusLabels" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
<span v-else>{{ examStatusLabels[row.examStatus] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-if="detail.canEdit" v-model="row.notes" maxlength="300" />
|
||||
<span v-else>{{ row.notes || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<footer class="grade-actions">
|
||||
<div>
|
||||
<span>当前流程</span>
|
||||
<b>{{ statusLabels[detail.status] }}</b>
|
||||
</div>
|
||||
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
|
||||
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
|
||||
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
|
||||
<el-button v-if="detail.canReview" type="success" :icon="Check" @click="approveSheet">审核通过</el-button>
|
||||
<el-button v-if="detail.canPublish" type="primary" :icon="Promotion" @click="publishSheet">发布成绩</el-button>
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="createDialog" title="建立成绩登记册" width="560px">
|
||||
<el-alert title="成绩构成比例合计必须为 100%" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="grade-weight-form">
|
||||
<div class="form-grid three">
|
||||
<el-form-item label="平时成绩">
|
||||
<el-input-number v-model="createForm.regularWeight" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
<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-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createSheet">建立登记册</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="returnDialog" title="退回成绩单" width="560px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="修改意见" required>
|
||||
<el-input v-model="returnComment" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="returnDialog = false">取消</el-button>
|
||||
<el-button type="danger" @click="returnSheet">确认退回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user