已按“实验成绩独立管理”完成修改。

每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。
流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。
自主预约实验支持同步有效预约名单,已评分记录不会被误删。
课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。
实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。
一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。
已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。
界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
This commit is contained in:
2026-07-28 22:21:18 +08:00 Unverified
parent 51c3b81e27
commit 8f56548b54
17 changed files with 9239 additions and 14 deletions
@@ -0,0 +1,945 @@
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 Jiaowu.Api.Infrastructure.Teaching;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/experiment-grades")]
public sealed class ExperimentGradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string Managers =
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("management")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetManagement(
Guid? academicTermId,
ExperimentGradeSheetStatus? status,
CancellationToken cancellationToken)
{
var source = ScopedProjects().AsNoTracking()
.Where(x =>
x.Status == ExperimentProjectStatus.Published ||
x.Status == ExperimentProjectStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x =>
x.TeachingTask!.AcademicTermId == academicTermId);
if (status.HasValue)
source = source.Where(x =>
x.GradeSheet != null &&
x.GradeSheet.Status == status.Value);
return Ok(await source
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.TeachingTask!.TaskNumber)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.ArrangementMode,
ProjectStatus = x.Status,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
AcademicTermId = x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
Sheet = x.GradeSheet == null
? null
: new
{
x.GradeSheet.Id,
x.GradeSheet.Status,
x.GradeSheet.ContributionWeight,
x.GradeSheet.PassScore,
ItemCount = x.GradeSheet.Items.Count,
StudentCount = x.GradeSheet.Records.Count,
CompletedCount = x.GradeSheet.Records.Count(record =>
record.ParticipationStatus !=
ExperimentParticipationStatus.Pending),
ScoredCount = x.GradeSheet.Records.Count(record =>
record.TotalScore != null),
PassedCount = x.GradeSheet.Records.Count(record =>
record.IsPassed == true),
x.GradeSheet.SubmittedAt,
x.GradeSheet.ReviewedAt,
x.GradeSheet.PublishedAt
}
})
.ToListAsync(cancellationToken));
}
[HttpGet("mine")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
{
var student = await CurrentStudentAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.ExperimentGradeSheet!.Status ==
ExperimentGradeSheetStatus.Published)
.OrderByDescending(x =>
x.ExperimentGradeSheet!.PublishedAt)
.ThenBy(x =>
x.ExperimentGradeSheet!.ExperimentProject!.Code)
.Select(x => new
{
x.Id,
x.ExperimentGradeSheetId,
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
ProjectCode =
x.ExperimentGradeSheet.ExperimentProject!.Code,
ProjectName =
x.ExperimentGradeSheet.ExperimentProject.Name,
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
CourseCode = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask!.Course!.Code,
CourseName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.Course.Name,
TermName = x.ExperimentGradeSheet.ExperimentProject
.TeachingTask.AcademicTerm!.Name,
x.ExperimentGradeSheet.PassScore,
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
Items = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.ExperimentGradeItem.Kind,
score.ExperimentGradeItem.Weight,
score.Score,
score.Comment
}),
x.ExperimentGradeSheet.PublishedAt
})
.ToListAsync(cancellationToken));
}
[HttpPost("sheets")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateSheet(
ExperimentGradeSheetRequest request,
CancellationToken cancellationToken)
{
var items = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
items))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var project = await ScopedProjects()
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x =>
x.Id == request.ExperimentProjectId,
cancellationToken);
if (project is null) return NotFound();
if (project.Status is not (
ExperimentProjectStatus.Published or
ExperimentProjectStatus.Closed))
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
if (!CanInitialize(project.TeachingTask!)) return Forbid();
if (await db.ExperimentGradeSheets.AnyAsync(x =>
x.ExperimentProjectId == project.Id,
cancellationToken))
return ConflictProblem("该实验项目已经建立成绩单。");
var participants = await LoadParticipantsAsync(
project,
cancellationToken);
if (participants.Count == 0)
return ConflictProblem("实验项目当前没有可评分学生。");
var sheet = new ExperimentGradeSheet
{
ExperimentProjectId = project.Id,
ContributionWeight = request.ContributionWeight,
PassScore = request.PassScore,
Items = items,
Records = participants.Select(participant =>
new ExperimentGradeRecord
{
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId,
ItemScores = items.Select(item =>
new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
}).ToList()
}).ToList()
};
db.ExperimentGradeSheets.Add(sheet);
await db.SaveChangesAsync(cancellationToken);
return Created(string.Empty, new { sheet.Id });
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetSheet(
Guid id,
int recordPage = 1,
int recordPageSize = 50,
string? studentKeyword = null,
CancellationToken cancellationToken = default)
{
recordPage = Math.Max(1, recordPage);
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
? null
: studentKeyword.Trim();
var sheet = await ScopedSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.ExperimentProjectId,
ProjectCode = x.ExperimentProject!.Code,
ProjectName = x.ExperimentProject.Name,
x.ExperimentProject.ArrangementMode,
x.ExperimentProject.TeachingTaskId,
x.ExperimentProject.TeachingTask!.TaskNumber,
TaskName = x.ExperimentProject.TeachingTask.Name,
AcademicTermId =
x.ExperimentProject.TeachingTask.AcademicTermId,
TermName = x.ExperimentProject.TeachingTask
.AcademicTerm!.Name,
CourseCollegeId = x.ExperimentProject.TeachingTask
.Course!.CollegeId,
CourseCollegeName = x.ExperimentProject.TeachingTask
.Course.College!.Name,
CourseCode =
x.ExperimentProject.TeachingTask.Course.Code,
CourseName =
x.ExperimentProject.TeachingTask.Course.Name,
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
x.ContributionWeight,
x.PassScore,
Items = x.Items.OrderBy(item => item.SortOrder)
.Select(item => new
{
item.Id,
item.Name,
item.Kind,
item.Weight
}),
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
.Where(x => x.ExperimentGradeSheetId == id);
if (studentKeyword is not null)
recordsSource = recordsSource.Where(x =>
x.Student!.StudentNumber.Contains(studentKeyword) ||
x.Student.Name.Contains(studentKeyword) ||
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
var recordTotal = await recordsSource.CountAsync(cancellationToken);
var records = await recordsSource
.OrderBy(x => x.Student!.StudentNumber)
.Skip((recordPage - 1) * recordPageSize)
.Take(recordPageSize)
.Select(x => new
{
x.Id,
x.StudentId,
x.Student!.StudentNumber,
x.Student.Name,
ClassName = x.Student.AdministrativeClass!.Name,
x.ExperimentSessionId,
Session = x.ExperimentSession == null
? null
: new
{
x.ExperimentSession.SessionDate,
x.ExperimentSession.StartPeriod,
x.ExperimentSession.PeriodCount,
ClassroomName =
x.ExperimentSession.Classroom!.Name
},
x.ParticipationStatus,
x.TotalScore,
x.IsPassed,
x.SafetyViolation,
x.AttemptNumber,
x.SubmissionReference,
x.SubmittedAt,
x.IsLate,
x.TeacherComment,
ItemScores = x.ItemScores
.OrderBy(score =>
score.ExperimentGradeItem!.SortOrder)
.Select(score => new
{
score.ExperimentGradeItemId,
score.ExperimentGradeItem!.Name,
score.Score,
score.Comment
}),
x.UpdatedAt
})
.ToListAsync(cancellationToken);
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.SingleAsync(
x => x.Id == sheet.TeachingTaskId,
cancellationToken);
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
sheet.Status ==
ExperimentGradeSheetStatus.Submitted;
return Ok(new
{
Sheet = new
{
sheet.Id,
sheet.ExperimentProjectId,
sheet.ProjectCode,
sheet.ProjectName,
sheet.ArrangementMode,
sheet.TeachingTaskId,
sheet.TaskNumber,
sheet.TaskName,
sheet.AcademicTermId,
sheet.TermName,
sheet.CourseCollegeName,
sheet.CourseCode,
sheet.CourseName,
sheet.TeacherNames,
sheet.ContributionWeight,
sheet.PassScore,
sheet.Items,
sheet.Status,
sheet.ReviewComment,
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
RecordTotal = recordTotal,
RecordPage = recordPage,
RecordPageSize = recordPageSize,
Records = records,
sheet.CreatedAt,
sheet.UpdatedAt
},
CanEdit = CanEdit(task) &&
sheet.Status is
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned,
CanReview = canReview,
CanPublish = IsPublisher() &&
sheet.Status == ExperimentGradeSheetStatus.Approved
});
}
[HttpPut("sheets/{id:guid}/settings")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateSettings(
Guid id,
ExperimentGradeSettingsRequest request,
CancellationToken cancellationToken)
{
var replacementItems = BuildItems(request.Items);
if (!ExperimentGradeCalculator.AreWeightsValid(
request.ContributionWeight,
request.PassScore,
replacementItems))
return ValidationProblem(
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改实验评分方案。");
sheet.ContributionWeight = request.ContributionWeight;
sheet.PassScore = request.PassScore;
db.ExperimentGradeItems.RemoveRange(sheet.Items);
foreach (var record in sheet.Records)
{
record.ItemScores.Clear();
record.TotalScore = null;
record.IsPassed = null;
}
sheet.Items = replacementItems;
foreach (var record in sheet.Records)
foreach (var item in replacementItems)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> UpdateRecords(
Guid id,
ExperimentGradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.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("包含无效或重复的实验成绩记录。");
var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
foreach (var item in request.Records)
{
if (item.AttemptNumber is < 1 or > 20)
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
.Distinct().Count() != item.ItemScores.Count ||
item.ItemScores.Any(x =>
!itemIds.Contains(x.ExperimentGradeItemId) ||
!ValidScore(x.Score)))
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
var record = records[item.Id];
record.ParticipationStatus = item.ParticipationStatus;
record.SafetyViolation = item.SafetyViolation;
record.AttemptNumber = item.AttemptNumber;
record.SubmissionReference =
Normalize(item.SubmissionReference);
record.SubmittedAt = item.SubmittedAt;
record.IsLate = item.IsLate;
record.TeacherComment = Normalize(item.TeacherComment);
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
foreach (var score in item.ItemScores)
{
var target = scores[score.ExperimentGradeItemId];
target.Score = score.Score;
target.Comment = Normalize(score.Comment);
}
Recalculate(sheet, record);
}
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能同步参与学生。");
var expected = await LoadParticipantsAsync(
sheet.ExperimentProject,
cancellationToken);
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
var added = 0;
var removed = 0;
var retained = 0;
var recordsToRemove = new List<ExperimentGradeRecord>();
var recordsToAdd = new List<ExperimentGradeRecord>();
foreach (var participant in expected)
{
if (existingByStudent.TryGetValue(
participant.StudentId,
out var record))
{
if (record.ParticipationStatus ==
ExperimentParticipationStatus.Pending &&
record.ItemScores.All(x => !x.Score.HasValue))
record.ExperimentSessionId = participant.SessionId;
retained++;
continue;
}
var newRecord = new ExperimentGradeRecord
{
ExperimentGradeSheetId = sheet.Id,
StudentId = participant.StudentId,
ExperimentSessionId = participant.SessionId
};
db.ExperimentGradeRecords.Add(newRecord);
recordsToAdd.Add(newRecord);
added++;
}
foreach (var record in sheet.Records
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
.ToList())
{
if (record.ParticipationStatus !=
ExperimentParticipationStatus.Pending ||
record.ItemScores.Any(x => x.Score.HasValue))
{
retained++;
continue;
}
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
recordsToRemove.Add(record);
removed++;
}
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
{
await db.SaveChangesAsync(cancellationToken);
foreach (var record in recordsToRemove)
sheet.Records.Remove(record);
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
}
foreach (var record in recordsToAdd)
foreach (var item in sheet.Items)
record.ItemScores.Add(new ExperimentGradeItemScore
{
ExperimentGradeItemId = item.Id
});
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
Added = added,
Removed = removed,
Retained = retained,
Total = await db.ExperimentGradeRecords.CountAsync(
x => x.ExperimentGradeSheetId == sheet.Id,
cancellationToken)
});
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> Submit(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
if (sheet.Status is not (
ExperimentGradeSheetStatus.Draft or
ExperimentGradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
if (sheet.Records.Count == 0)
return ConflictProblem("实验成绩单没有学生记录。");
foreach (var record in sheet.Records) Recalculate(sheet, record);
if (sheet.Records.Any(x =>
x.ParticipationStatus ==
ExperimentParticipationStatus.Pending))
return ConflictProblem("仍有学生未登记实验参与状态。");
if (sheet.Records.Any(x =>
x.ParticipationStatus is
ExperimentParticipationStatus.Completed or
ExperimentParticipationStatus.Makeup &&
!x.TotalScore.HasValue))
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
sheet.Status = ExperimentGradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotificationService.SendToRoleAsync(
db,
SystemRoles.CollegeAdmin,
"实验成绩待审核",
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
[HttpPost("sheets/{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
sheet.Status = ExperimentGradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩审核通过",
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/return")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Return(
Guid id,
ExperimentGradeReviewRequest request,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
if (!CanReviewCollege(collegeId)) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
return ConflictProblem("只有待审核实验成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment))
return ValidationProblem("退回时必须填写修改意见。");
sheet.Status = ExperimentGradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
await db.SaveChangesAsync(cancellationToken);
await NotifyTeachersAsync(
sheet.ExperimentProject.TeachingTaskId,
"实验成绩被退回",
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
cancellationToken);
return NoContent();
}
[HttpPost("sheets/{id:guid}/publish")]
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!IsPublisher()) return Forbid();
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
sheet.Status = ExperimentGradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var userIds = await db.ExperimentGradeRecords
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
.Select(x => x.Student!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
"实验成绩已发布",
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
return NoContent();
}
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
Guid id,
CancellationToken cancellationToken)
{
if (!await ScopedSheets().AnyAsync(
x => x.Id == id,
cancellationToken))
return null;
return await db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
}
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project,
CancellationToken cancellationToken)
{
if (project.ArrangementMode ==
ExperimentArrangementMode.Centralized)
{
return await TeachingTaskRosterQuery
.ForTask(db, project.TeachingTaskId)
.AsNoTracking()
.OrderBy(x => x.StudentNumber)
.Select(x => new ExperimentParticipantSeed(x.Id, null))
.ToListAsync(cancellationToken);
}
return await db.ExperimentBookings.AsNoTracking()
.Where(x =>
x.ExperimentProjectId == project.Id &&
x.Status == ExperimentBookingStatus.Booked &&
x.Student!.Status == StudentStatus.Active)
.OrderBy(x => x.Student!.StudentNumber)
.Select(x => new ExperimentParticipantSeed(
x.StudentId,
x.ExperimentSessionId))
.ToListAsync(cancellationToken);
}
private static List<ExperimentGradeItem> BuildItems(
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
requests?.Select((item, index) => new ExperimentGradeItem
{
Name = item.Name.Trim(),
Kind = item.Kind,
Weight = item.Weight,
SortOrder = index
}).ToList() ?? [];
private static void Recalculate(
ExperimentGradeSheet sheet,
ExperimentGradeRecord record)
{
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
record,
sheet.Items.ToList());
record.IsPassed = record.TotalScore.HasValue
? record.TotalScore.Value >= sheet.PassScore &&
!record.SafetyViolation
: null;
}
private IQueryable<TeachingTask> AccessibleTeachingTasks()
{
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<ExperimentProject> ScopedProjects()
{
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
return db.ExperimentProjects.Where(x =>
taskIds.Contains(x.TeachingTaskId));
}
private IQueryable<ExperimentGradeSheet> ScopedSheets()
{
var projectIds = ScopedProjects().Select(x => x.Id);
return db.ExperimentGradeSheets.Where(x =>
projectIds.Contains(x.ExperimentProjectId));
}
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
IsAssignedTeacher(task);
private bool CanEdit(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 CanReviewCollege(Guid collegeId)
{
var scope = currentUserDataScope.Current;
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
!scope.IsInRole(SystemRoles.CollegeAdmin))
return false;
return scope.Scope == DataScope.All ||
scope.RestrictedCollegeId == collegeId;
}
private bool IsPublisher() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private Task<Student?> CurrentStudentAsync(
CancellationToken cancellationToken) =>
db.Students.FirstOrDefaultAsync(x =>
x.UserId == currentUserDataScope.Current.UserId &&
x.Status == StudentStatus.Active,
cancellationToken);
private async Task NotifyTeachersAsync(
Guid teachingTaskId,
string title,
string content,
CancellationToken cancellationToken)
{
var userIds = await db.TeachingTaskTeachers
.Where(x => x.TeachingTaskId == teachingTaskId)
.Select(x => x.Teacher!.UserId)
.Where(x => x != null)
.Select(x => x!.Value)
.Distinct()
.ToListAsync(cancellationToken);
await NotificationService.SendToUserIdsAsync(
db,
userIds,
title,
content,
"/experiment-grades",
cancellationToken,
NotificationCategory.Grade);
}
private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100;
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
});
private sealed record ExperimentParticipantSeed(
Guid StudentId,
Guid? SessionId);
}
public sealed record ExperimentGradeSheetRequest(
Guid ExperimentProjectId,
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeSettingsRequest(
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
[Range(typeof(decimal), "0", "100")] decimal PassScore,
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
public sealed record ExperimentGradeItemRequest(
[Required, MaxLength(60)] string Name,
ExperimentGradeItemKind Kind,
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
public sealed record ExperimentGradeRecordsRequest(
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
public sealed record ExperimentGradeRecordRequest(
Guid Id,
ExperimentParticipationStatus ParticipationStatus,
bool SafetyViolation,
[Range(1, 20)] int AttemptNumber,
[MaxLength(500)] string? SubmissionReference,
DateTime? SubmittedAt,
bool IsLate,
[MaxLength(500)] string? TeacherComment,
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
public sealed record ExperimentGradeItemScoreRequest(
Guid ExperimentGradeItemId,
decimal? Score,
[MaxLength(300)] string? Comment);
public sealed record ExperimentGradeReviewRequest(
[MaxLength(500)] string? Comment);
+81 -4
View File
@@ -101,7 +101,9 @@ public sealed class GradesController(
{ {
item.Id, item.Id,
item.Name, item.Name,
item.Weight item.Weight,
item.SourceType,
item.SourceSnapshotAt
}), }),
StudentCount = sheet.Records.Count, StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record => CompletedCount = sheet.Records.Count(record =>
@@ -220,7 +222,9 @@ public sealed class GradesController(
{ {
item.Id, item.Id,
item.Name, item.Name,
item.Weight item.Weight,
item.SourceType,
item.SourceSnapshotAt
}), }),
x.Status, x.Status,
x.ReviewComment, x.ReviewComment,
@@ -408,7 +412,7 @@ public sealed class GradesController(
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(); var itemsById = sheet.Items.ToDictionary(item => item.Id);
foreach (var item in request.Records) foreach (var item in request.Records)
{ {
if (!ValidScore(item.RegularScore) || if (!ValidScore(item.RegularScore) ||
@@ -428,7 +432,18 @@ public sealed class GradesController(
{ {
if (!ValidScore(scoreEntry.Score)) if (!ValidScore(scoreEntry.Score))
return ValidationProblem("分项成绩必须在 0—100 分之间。"); return ValidationProblem("分项成绩必须在 0—100 分之间。");
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing)) if (!itemsById.TryGetValue(
scoreEntry.GradeItemId,
out var gradeItem) ||
!scoreMap.TryGetValue(
scoreEntry.GradeItemId,
out var existing))
continue;
if (gradeItem.SourceType ==
GradeItemSourceType.ExperimentSummary &&
existing.Score != scoreEntry.Score)
return ConflictProblem(
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
existing.Score = scoreEntry.Score; existing.Score = scoreEntry.Score;
} }
} }
@@ -438,6 +453,66 @@ public sealed class GradesController(
return await SaveAsync(id, false, cancellationToken); return await SaveAsync(id, false, cancellationToken);
} }
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> ImportExperimentScores(
Guid id,
ImportExperimentScoresRequest request,
CancellationToken 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 (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (
GradeSheetStatus.Draft or
GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能导入实验成绩。");
var targetItem = sheet.Items.FirstOrDefault(x =>
x.Id == request.GradeItemId);
if (targetItem is null)
return ValidationProblem("请选择当前成绩单中的有效分项。");
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
db,
sheet.TeachingTaskId,
sheet.Records.Select(x => x.StudentId).ToArray(),
cancellationToken);
if (aggregate.PublishedProjectCount == 0)
return ConflictProblem(
"该教学任务尚无已发布的实验项目成绩,无法导入。");
var imported = 0;
var missing = 0;
foreach (var record in sheet.Records)
{
var targetScore = record.ItemScores.First(x =>
x.GradeItemId == targetItem.Id);
targetScore.Score = aggregate.Scores[record.StudentId];
if (targetScore.Score.HasValue) imported++;
else missing++;
Recalculate(sheet, record);
}
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
targetItem.SourceSnapshotAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return Ok(new
{
aggregate.PublishedProjectCount,
aggregate.Projects,
ImportedCount = imported,
MissingCount = missing,
targetItem.SourceSnapshotAt
});
}
[HttpPost("sheets/{id:guid}/submit")] [HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)] [Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
@@ -998,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
Guid GradeItemId, Guid GradeItemId,
decimal? Score); decimal? Score);
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest( public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment); [MaxLength(500)] string? Comment);
@@ -19,6 +19,7 @@ public sealed class ExperimentProject : EntityBase
public DateTime? ClosedAt { get; set; } public DateTime? ClosedAt { get; set; }
public ICollection<ExperimentSession> Sessions { get; set; } = []; public ICollection<ExperimentSession> Sessions { get; set; } = [];
public ICollection<ExperimentBooking> Bookings { get; set; } = []; public ICollection<ExperimentBooking> Bookings { get; set; } = [];
public ExperimentGradeSheet? GradeSheet { get; set; }
} }
public sealed class ExperimentSession : EntityBase public sealed class ExperimentSession : EntityBase
@@ -0,0 +1,92 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class ExperimentGradeSheet : EntityBase
{
public Guid ExperimentProjectId { get; set; }
public ExperimentProject? ExperimentProject { get; set; }
public decimal ContributionWeight { get; set; } = 1;
public decimal PassScore { get; set; } = 60;
public ExperimentGradeSheetStatus Status { get; set; } =
ExperimentGradeSheetStatus.Draft;
public string? ReviewComment { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
}
public sealed class ExperimentGradeItem : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public required string Name { get; set; }
public ExperimentGradeItemKind Kind { get; set; } =
ExperimentGradeItemKind.Other;
public decimal Weight { get; set; }
public int SortOrder { get; set; }
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
}
public sealed class ExperimentGradeRecord : EntityBase
{
public Guid ExperimentGradeSheetId { get; set; }
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public Guid? ExperimentSessionId { get; set; }
public ExperimentSession? ExperimentSession { get; set; }
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
ExperimentParticipationStatus.Pending;
public decimal? TotalScore { get; set; }
public bool? IsPassed { get; set; }
public bool SafetyViolation { get; set; }
public int AttemptNumber { get; set; } = 1;
public string? SubmissionReference { get; set; }
public DateTime? SubmittedAt { get; set; }
public bool IsLate { get; set; }
public string? TeacherComment { get; set; }
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
}
public sealed class ExperimentGradeItemScore
{
public Guid ExperimentGradeRecordId { get; set; }
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
public Guid ExperimentGradeItemId { get; set; }
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
public decimal? Score { get; set; }
public string? Comment { get; set; }
}
public enum ExperimentGradeSheetStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Published = 4,
Returned = 5
}
public enum ExperimentGradeItemKind
{
Operation = 1,
Report = 2,
Result = 3,
Defense = 4,
Attendance = 5,
Safety = 6,
Other = 7
}
public enum ExperimentParticipationStatus
{
Pending = 1,
Completed = 2,
Absent = 3,
Excused = 4,
Makeup = 5,
Exempt = 6
}
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
public required string Name { get; set; } public required string Name { get; set; }
public decimal Weight { get; set; } public decimal Weight { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public GradeItemSourceType SourceType { get; set; } =
GradeItemSourceType.Manual;
public DateTime? SourceSnapshotAt { get; set; }
public ICollection<GradeItemScore> Scores { get; set; } = []; public ICollection<GradeItemScore> Scores { get; set; } = [];
} }
@@ -68,3 +71,9 @@ public enum GradeExamStatus
Exempt = 4, Exempt = 4,
Makeup = 5 Makeup = 5
} }
public enum GradeItemSourceType
{
Manual = 1,
ExperimentSummary = 2
}
@@ -0,0 +1,88 @@
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeAggregationService
{
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
AppDbContext db,
Guid teachingTaskId,
IReadOnlyCollection<Guid> studentIds,
CancellationToken cancellationToken)
{
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
.Where(x =>
x.Status ==
Domain.Academic.ExperimentGradeSheetStatus.Published &&
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
.OrderBy(x => x.ExperimentProject!.Code)
.Select(x => new PublishedExperimentSheet(
x.Id,
x.ExperimentProject!.Code,
x.ExperimentProject.Name,
x.ContributionWeight,
x.Records.Select(record => new PublishedExperimentScore(
record.StudentId,
record.TotalScore)).ToList()))
.AsSplitQuery()
.ToListAsync(cancellationToken);
var scores = new Dictionary<Guid, decimal?>();
foreach (var studentId in studentIds.Distinct())
{
decimal weightedTotal = 0;
decimal totalWeight = 0;
var complete = sheets.Count > 0;
foreach (var sheet in sheets)
{
var score = sheet.Scores.FirstOrDefault(x =>
x.StudentId == studentId);
if (score?.TotalScore is not decimal totalScore)
{
complete = false;
break;
}
weightedTotal += totalScore * sheet.ContributionWeight;
totalWeight += sheet.ContributionWeight;
}
scores[studentId] = complete && totalWeight > 0
? Math.Round(
weightedTotal / totalWeight,
1,
MidpointRounding.AwayFromZero)
: null;
}
return new ExperimentGradeAggregateResult(
sheets.Count,
sheets.Select(x => new ExperimentGradeAggregateProject(
x.Id,
x.Code,
x.Name,
x.ContributionWeight)).ToList(),
scores);
}
private sealed record PublishedExperimentSheet(
Guid Id,
string Code,
string Name,
decimal ContributionWeight,
List<PublishedExperimentScore> Scores);
private sealed record PublishedExperimentScore(
Guid StudentId,
decimal? TotalScore);
}
public sealed record ExperimentGradeAggregateResult(
int PublishedProjectCount,
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
IReadOnlyDictionary<Guid, decimal?> Scores);
public sealed record ExperimentGradeAggregateProject(
Guid Id,
string Code,
string Name,
decimal ContributionWeight);
@@ -0,0 +1,45 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class ExperimentGradeCalculator
{
public static bool AreWeightsValid(
decimal contributionWeight,
decimal passScore,
IEnumerable<ExperimentGradeItem> items)
{
var itemList = items.ToList();
return contributionWeight is > 0 and <= 100 &&
passScore is >= 0 and <= 100 &&
itemList.Count > 0 &&
itemList.All(item => item.Weight is > 0 and <= 100) &&
itemList.Sum(item => item.Weight) == 100;
}
public static decimal? CalculateTotal(
ExperimentGradeRecord record,
IReadOnlyCollection<ExperimentGradeItem> items)
{
if (record.SafetyViolation ||
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
return 0;
if (record.ParticipationStatus is
ExperimentParticipationStatus.Pending or
ExperimentParticipationStatus.Excused or
ExperimentParticipationStatus.Exempt)
return null;
var scores = record.ItemScores.ToDictionary(
x => x.ExperimentGradeItemId);
decimal total = 0;
foreach (var item in items)
{
if (!scores.TryGetValue(item.Id, out var score) ||
!score.Score.HasValue)
return null;
total += score.Score.Value * item.Weight / 100;
}
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
}
@@ -47,6 +47,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>(); public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>(); public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>(); public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
Set<ExperimentGradeSheet>();
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
Set<ExperimentGradeItem>();
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
Set<ExperimentGradeRecord>();
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
Set<ExperimentGradeItemScore>();
public DbSet<CourseSelectionRound> CourseSelectionRounds => public DbSet<CourseSelectionRound> CourseSelectionRounds =>
Set<CourseSelectionRound>(); Set<CourseSelectionRound>();
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades => public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
@@ -608,6 +616,75 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ExperimentGradeSheet>(entity =>
{
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
entity.Property(x => x.PassScore).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
entity.HasOne(x => x.ExperimentProject)
.WithOne(x => x.GradeSheet)
.HasForeignKey<ExperimentGradeSheet>(
x => x.ExperimentProjectId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeItem>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.SortOrder
});
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Items)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<ExperimentGradeRecord>(entity =>
{
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
entity.Property(x => x.TeacherComment).HasMaxLength(500);
entity.HasIndex(x => new
{
x.ExperimentGradeSheetId,
x.StudentId
}).IsUnique();
entity.HasOne(x => x.ExperimentGradeSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.ExperimentGradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student).WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(x => x.ExperimentSession).WithMany()
.HasForeignKey(x => x.ExperimentSessionId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<ExperimentGradeItemScore>(entity =>
{
entity.HasKey(x => new
{
x.ExperimentGradeRecordId,
x.ExperimentGradeItemId
});
entity.Property(x => x.Score).HasPrecision(5, 1);
entity.Property(x => x.Comment).HasMaxLength(300);
entity.HasOne(x => x.ExperimentGradeRecord)
.WithMany(x => x.ItemScores)
.HasForeignKey(x => x.ExperimentGradeRecordId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.ExperimentGradeItem)
.WithMany(x => x.Scores)
.HasForeignKey(x => x.ExperimentGradeItemId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CourseSelectionRound>(entity => builder.Entity<CourseSelectionRound>(entity =>
{ {
entity.Property(x => x.Name).HasMaxLength(120); entity.Property(x => x.Name).HasMaxLength(120);
@@ -687,6 +764,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{ {
entity.Property(x => x.Name).HasMaxLength(60); entity.Property(x => x.Name).HasMaxLength(60);
entity.Property(x => x.Weight).HasPrecision(5, 1); entity.Property(x => x.Weight).HasPrecision(5, 1);
entity.Property(x => x.SourceType)
.HasDefaultValue(GradeItemSourceType.Manual);
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder }); entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
entity.HasOne(x => x.GradeSheet) entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Items) .WithMany(x => x.Items)
@@ -72,6 +72,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260728_38_exam_publish_jobs"; "20260728_38_exam_publish_jobs";
private const string ExperimentManagementMigration = private const string ExperimentManagementMigration =
"20260728_39_experiment_management"; "20260728_39_experiment_management";
private const string ExperimentGradeManagementMigration =
"20260728_40_experiment_grade_management";
public async Task MigrateAsync(CancellationToken cancellationToken = default) public async Task MigrateAsync(CancellationToken cancellationToken = default)
{ {
@@ -543,6 +545,32 @@ public sealed class DevelopmentSqliteMigrator(
ExperimentManagementMigration, ExperimentManagementMigration,
experimentProjectsExist ? [] : ExperimentManagementStatements, experimentProjectsExist ? [] : ExperimentManagementStatements,
cancellationToken); cancellationToken);
var gradeItemSourceExists = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM pragma_table_info('GradeItems')
WHERE name = 'SourceType'
""")
.AnyAsync(value => value > 0, cancellationToken);
var experimentGradeSheetsExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'ExperimentGradeSheets'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
ExperimentGradeManagementMigration,
(gradeItemSourceExists
? []
: ExperimentGradeItemSourceStatements)
.Concat(experimentGradeSheetsExist
? []
: ExperimentGradeManagementStatements),
cancellationToken);
} }
private async Task ApplyMigrationAsync( private async Task ApplyMigrationAsync(
@@ -2511,4 +2539,133 @@ public sealed class DevelopmentSqliteMigrator(
); );
""" """
]; ];
private static readonly string[] ExperimentGradeItemSourceStatements =
[
"""
ALTER TABLE "GradeItems"
ADD "SourceType" INTEGER NOT NULL DEFAULT 1;
""",
"""
ALTER TABLE "GradeItems"
ADD "SourceSnapshotAt" TEXT NULL;
"""
];
private static readonly string[] ExperimentGradeManagementStatements =
[
"""
CREATE TABLE "ExperimentGradeSheets" (
"Id" TEXT NOT NULL
CONSTRAINT "PK_ExperimentGradeSheets" PRIMARY KEY,
"ExperimentProjectId" TEXT NOT NULL,
"ContributionWeight" TEXT NOT NULL,
"PassScore" 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_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId"
FOREIGN KEY ("ExperimentProjectId")
REFERENCES "ExperimentProjects" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE UNIQUE INDEX "IX_ExperimentGradeSheets_ExperimentProjectId"
ON "ExperimentGradeSheets" ("ExperimentProjectId");
""",
"""
CREATE TABLE "ExperimentGradeItems" (
"Id" TEXT NOT NULL
CONSTRAINT "PK_ExperimentGradeItems" PRIMARY KEY,
"ExperimentGradeSheetId" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Kind" INTEGER NOT NULL,
"Weight" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSheetId"
FOREIGN KEY ("ExperimentGradeSheetId")
REFERENCES "ExperimentGradeSheets" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE INDEX "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder"
ON "ExperimentGradeItems" ("ExperimentGradeSheetId", "SortOrder");
""",
"""
CREATE TABLE "ExperimentGradeRecords" (
"Id" TEXT NOT NULL
CONSTRAINT "PK_ExperimentGradeRecords" PRIMARY KEY,
"ExperimentGradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"ExperimentSessionId" TEXT NULL,
"ParticipationStatus" INTEGER NOT NULL,
"TotalScore" TEXT NULL,
"IsPassed" INTEGER NULL,
"SafetyViolation" INTEGER NOT NULL,
"AttemptNumber" INTEGER NOT NULL,
"SubmissionReference" TEXT NULL,
"SubmittedAt" TEXT NULL,
"IsLate" INTEGER NOT NULL,
"TeacherComment" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGradeSheetId"
FOREIGN KEY ("ExperimentGradeSheetId")
REFERENCES "ExperimentGradeSheets" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentGradeRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
ON DELETE RESTRICT,
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId"
FOREIGN KEY ("ExperimentSessionId")
REFERENCES "ExperimentSessions" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId"
ON "ExperimentGradeRecords" ("ExperimentGradeSheetId", "StudentId");
""",
"""
CREATE INDEX "IX_ExperimentGradeRecords_StudentId"
ON "ExperimentGradeRecords" ("StudentId");
""",
"""
CREATE INDEX "IX_ExperimentGradeRecords_ExperimentSessionId"
ON "ExperimentGradeRecords" ("ExperimentSessionId");
""",
"""
CREATE TABLE "ExperimentGradeItemScores" (
"ExperimentGradeRecordId" TEXT NOT NULL,
"ExperimentGradeItemId" TEXT NOT NULL,
"Score" TEXT NULL,
"Comment" TEXT NULL,
CONSTRAINT "PK_ExperimentGradeItemScores"
PRIMARY KEY (
"ExperimentGradeRecordId",
"ExperimentGradeItemId"
),
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentGradeRecordId"
FOREIGN KEY ("ExperimentGradeRecordId")
REFERENCES "ExperimentGradeRecords" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGradeItemId"
FOREIGN KEY ("ExperimentGradeItemId")
REFERENCES "ExperimentGradeItems" ("Id")
ON DELETE CASCADE
);
""",
"""
CREATE INDEX "IX_ExperimentGradeItemScores_ExperimentGradeItemId"
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
"""
];
} }
@@ -0,0 +1,208 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class ExperimentGradeManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "SourceSnapshotAt",
table: "GradeItems",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SourceType",
table: "GradeItems",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.CreateTable(
name: "ExperimentGradeSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
ContributionWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
PassScore = 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_ExperimentGradeSheets", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId",
column: x => x.ExperimentProjectId,
principalTable: "ExperimentProjects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentGradeItems",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
Kind = table.Column<int>(type: "int", 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_ExperimentGradeItems", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSh~",
column: x => x.ExperimentGradeSheetId,
principalTable: "ExperimentGradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentGradeRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: true),
ParticipationStatus = table.Column<int>(type: "int", nullable: false),
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
SafetyViolation = table.Column<bool>(type: "tinyint(1)", nullable: false),
AttemptNumber = table.Column<int>(type: "int", nullable: false),
SubmissionReference = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
IsLate = table.Column<bool>(type: "tinyint(1)", nullable: false),
TeacherComment = table.Column<string>(type: "varchar(500)", maxLength: 500, 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_ExperimentGradeRecords", x => x.Id);
table.ForeignKey(
name: "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGrade~",
column: x => x.ExperimentGradeSheetId,
principalTable: "ExperimentGradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId",
column: x => x.ExperimentSessionId,
principalTable: "ExperimentSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExperimentGradeRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "ExperimentGradeItemScores",
columns: table => new
{
ExperimentGradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
ExperimentGradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
Comment = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExperimentGradeItemScores", x => new { x.ExperimentGradeRecordId, x.ExperimentGradeItemId });
table.ForeignKey(
name: "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGra~",
column: x => x.ExperimentGradeItemId,
principalTable: "ExperimentGradeItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentG~",
column: x => x.ExperimentGradeRecordId,
principalTable: "ExperimentGradeRecords",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder",
table: "ExperimentGradeItems",
columns: new[] { "ExperimentGradeSheetId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeItemScores_ExperimentGradeItemId",
table: "ExperimentGradeItemScores",
column: "ExperimentGradeItemId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId",
table: "ExperimentGradeRecords",
columns: new[] { "ExperimentGradeSheetId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeRecords_ExperimentSessionId",
table: "ExperimentGradeRecords",
column: "ExperimentSessionId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeRecords_StudentId",
table: "ExperimentGradeRecords",
column: "StudentId");
migrationBuilder.CreateIndex(
name: "IX_ExperimentGradeSheets_ExperimentProjectId",
table: "ExperimentGradeSheets",
column: "ExperimentProjectId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExperimentGradeItemScores");
migrationBuilder.DropTable(
name: "ExperimentGradeItems");
migrationBuilder.DropTable(
name: "ExperimentGradeRecords");
migrationBuilder.DropTable(
name: "ExperimentGradeSheets");
migrationBuilder.DropColumn(
name: "SourceSnapshotAt",
table: "GradeItems");
migrationBuilder.DropColumn(
name: "SourceType",
table: "GradeItems");
}
}
}
@@ -2043,6 +2043,176 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("ExperimentBookings"); b.ToTable("ExperimentBookings");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ExperimentGradeSheetId")
.HasColumnType("char(36)");
b.Property<int>("Kind")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("varchar(60)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("Weight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("Id");
b.HasIndex("ExperimentGradeSheetId", "SortOrder");
b.ToTable("ExperimentGradeItems");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItemScore", b =>
{
b.Property<Guid>("ExperimentGradeRecordId")
.HasColumnType("char(36)");
b.Property<Guid>("ExperimentGradeItemId")
.HasColumnType("char(36)");
b.Property<string>("Comment")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<decimal?>("Score")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.HasKey("ExperimentGradeRecordId", "ExperimentGradeItemId");
b.HasIndex("ExperimentGradeItemId");
b.ToTable("ExperimentGradeItemScores");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("AttemptNumber")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ExperimentGradeSheetId")
.HasColumnType("char(36)");
b.Property<Guid?>("ExperimentSessionId")
.HasColumnType("char(36)");
b.Property<bool>("IsLate")
.HasColumnType("tinyint(1)");
b.Property<bool?>("IsPassed")
.HasColumnType("tinyint(1)");
b.Property<int>("ParticipationStatus")
.HasColumnType("int");
b.Property<bool>("SafetyViolation")
.HasColumnType("tinyint(1)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<string>("SubmissionReference")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<string>("TeacherComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<decimal?>("TotalScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ExperimentSessionId");
b.HasIndex("StudentId");
b.HasIndex("ExperimentGradeSheetId", "StudentId")
.IsUnique();
b.ToTable("ExperimentGradeRecords");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<decimal>("ContributionWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("ExperimentProjectId")
.HasColumnType("char(36)");
b.Property<decimal>("PassScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
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<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("ExperimentProjectId")
.IsUnique();
b.ToTable("ExperimentGradeSheets");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2176,6 +2346,14 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("SourceSnapshotAt")
.HasColumnType("datetime(6)");
b.Property<int>("SourceType")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(1);
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -4812,6 +4990,73 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Student"); b.Navigation("Student");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
.WithMany("Items")
.HasForeignKey("ExperimentGradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExperimentGradeSheet");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItemScore", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", "ExperimentGradeItem")
.WithMany("Scores")
.HasForeignKey("ExperimentGradeItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeRecord", "ExperimentGradeRecord")
.WithMany("ItemScores")
.HasForeignKey("ExperimentGradeRecordId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExperimentGradeItem");
b.Navigation("ExperimentGradeRecord");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeRecord", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentGradeSheet")
.WithMany("Records")
.HasForeignKey("ExperimentGradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentSession", "ExperimentSession")
.WithMany()
.HasForeignKey("ExperimentSessionId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ExperimentGradeSheet");
b.Navigation("ExperimentSession");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.ExperimentProject", "ExperimentProject")
.WithOne("GradeSheet")
.HasForeignKey("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", "ExperimentProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ExperimentProject");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
{ {
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
@@ -5550,10 +5795,29 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("SeatAssignments"); b.Navigation("SeatAssignments");
}); });
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeItem", b =>
{
b.Navigation("Scores");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeRecord", b =>
{
b.Navigation("ItemScores");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentGradeSheet", b =>
{
b.Navigation("Items");
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b => modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExperimentProject", b =>
{ {
b.Navigation("Bookings"); b.Navigation("Bookings");
b.Navigation("GradeSheet");
b.Navigation("Sessions"); b.Navigation("Sessions");
}); });
@@ -0,0 +1,454 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class ExperimentGradesControllerTests
{
[Fact]
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
fixture.Db.ExperimentProjects.Add(new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-C",
Name = "集中实验",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
});
await fixture.Db.SaveChangesAsync();
var project = await fixture.Db.ExperimentProjects.SingleAsync();
var experimentGrades = fixture.ExperimentGrades(fixture.AdminScope);
var created = await experimentGrades.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[
new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
50),
new ExperimentGradeItemRequest(
"报告",
ExperimentGradeItemKind.Report,
50)
]),
CancellationToken.None);
Assert.IsType<CreatedResult>(created);
var sheet = await fixture.Db.ExperimentGradeSheets
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.SingleAsync();
var record = Assert.Single(sheet.Records);
var operation = sheet.Items.Single(x => x.Name == "操作");
var report = sheet.Items.Single(x => x.Name == "报告");
Assert.IsType<NoContentResult>(
await experimentGrades.UpdateRecords(
sheet.Id,
new ExperimentGradeRecordsRequest(
[
new ExperimentGradeRecordRequest(
record.Id,
ExperimentParticipationStatus.Completed,
false,
1,
"https://example.test/report",
DateTime.UtcNow,
false,
"完成规范",
[
new ExperimentGradeItemScoreRequest(
operation.Id,
80,
"操作正确"),
new ExperimentGradeItemScoreRequest(
report.Id,
90,
"报告完整")
])
]),
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Submit(
sheet.Id,
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Approve(
sheet.Id,
CancellationToken.None));
Assert.IsType<NoContentResult>(
await experimentGrades.Publish(
sheet.Id,
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var publishedRecord = await fixture.Db.ExperimentGradeRecords
.SingleAsync();
Assert.Equal(85m, publishedRecord.TotalScore);
Assert.True(publishedRecord.IsPassed);
Assert.Equal(
ExperimentGradeSheetStatus.Published,
(await fixture.Db.ExperimentGradeSheets.SingleAsync()).Status);
var studentResult = await fixture
.ExperimentGrades(fixture.StudentScope)
.GetMine(CancellationToken.None);
Assert.IsType<OkObjectResult>(studentResult);
var courseSheet = new GradeSheet
{
TeachingTaskId = fixture.Task.Id,
RegularWeight = 0,
FinalWeight = 50,
Items =
[
new GradeItem
{
Name = "实验",
Weight = 50,
SortOrder = 0
}
],
Records =
[
new GradeRecord
{
StudentId = fixture.Student.Id,
FinalScore = 80
}
]
};
var courseItem = courseSheet.Items.Single();
courseSheet.Records.Single().ItemScores.Add(new GradeItemScore
{
GradeItemId = courseItem.Id
});
fixture.Db.GradeSheets.Add(courseSheet);
await fixture.Db.SaveChangesAsync();
var grades = new GradesController(fixture.Db, fixture.AdminScope);
Assert.IsType<OkObjectResult>(
await grades.ImportExperimentScores(
courseSheet.Id,
new ImportExperimentScoresRequest(courseItem.Id),
CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
var importedItem = await fixture.Db.GradeItems.SingleAsync();
var importedRecord = await fixture.Db.GradeRecords
.Include(x => x.ItemScores)
.SingleAsync();
Assert.Equal(
GradeItemSourceType.ExperimentSummary,
importedItem.SourceType);
Assert.NotNull(importedItem.SourceSnapshotAt);
Assert.Equal(85m, importedRecord.ItemScores.Single().Score);
Assert.Equal(82.5m, importedRecord.TotalScore);
var manualOverride = await grades.UpdateRecords(
courseSheet.Id,
new GradeRecordsRequest(
[
new GradeRecordRequest(
importedRecord.Id,
null,
80,
[new GradeItemScoreRequest(importedItem.Id, 90)],
GradeExamStatus.Normal,
null)
]),
CancellationToken.None);
Assert.IsType<ConflictObjectResult>(manualOverride);
}
[Fact]
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-S",
Name = "自主实验",
ArrangementMode = ExperimentArrangementMode.SelfScheduled,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
var session = new ExperimentSession
{
ExperimentProjectId = project.Id,
ClassroomId = fixture.Classroom.Id,
SessionDate = fixture.Term.StartDate,
StartPeriod = 1,
PeriodCount = 2,
Capacity = 10,
ReservedCount = 1
};
var booking = new ExperimentBooking
{
ExperimentProjectId = project.Id,
ExperimentSessionId = session.Id,
StudentId = fixture.Student.Id,
Status = ExperimentBookingStatus.Booked,
BookedAt = DateTime.UtcNow
};
fixture.Db.AddRange(project, session, booking);
await fixture.Db.SaveChangesAsync();
var controller = fixture.ExperimentGrades(fixture.AdminScope);
await controller.CreateSheet(
new ExperimentGradeSheetRequest(
project.Id,
1,
60,
[
new ExperimentGradeItemRequest(
"操作",
ExperimentGradeItemKind.Operation,
100)
]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
Assert.Single(fixture.Db.ExperimentGradeRecords);
booking.Status = ExperimentBookingStatus.Cancelled;
booking.CancelledAt = DateTime.UtcNow;
await fixture.Db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(
await controller.SyncParticipants(
sheet.Id,
CancellationToken.None));
Assert.Empty(fixture.Db.ExperimentGradeRecords);
fixture.Db.ChangeTracker.Clear();
booking = await fixture.Db.ExperimentBookings.SingleAsync();
controller = fixture.ExperimentGrades(fixture.AdminScope);
booking.Status = ExperimentBookingStatus.Booked;
booking.CancelledAt = null;
await fixture.Db.SaveChangesAsync();
Assert.IsType<OkObjectResult>(
await controller.SyncParticipants(
sheet.Id,
CancellationToken.None));
Assert.Single(fixture.Db.ExperimentGradeRecords);
}
private sealed class ExperimentGradeFixture : IAsyncDisposable
{
private ExperimentGradeFixture(
SqliteConnection connection,
AppDbContext db,
AcademicTerm term,
TeachingTask task,
Student student,
Classroom classroom,
ICurrentUserDataScope adminScope,
ICurrentUserDataScope studentScope)
{
Connection = connection;
Db = db;
Term = term;
Task = task;
Student = student;
Classroom = classroom;
AdminScope = adminScope;
StudentScope = studentScope;
}
private SqliteConnection Connection { get; }
public AppDbContext Db { get; }
public AcademicTerm Term { get; }
public TeachingTask Task { get; }
public Student Student { get; }
public Classroom Classroom { get; }
public ICurrentUserDataScope AdminScope { get; }
public ICurrentUserDataScope StudentScope { get; }
public static async Task<ExperimentGradeFixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options);
await db.Database.EnsureCreatedAsync();
var admin = User("lab-admin", "实验管理员");
var studentUser = User("lab-student", "实验学生");
var teacherUser = User("lab-teacher", "实验教师");
var campus = new Campus { Code = "MAIN", Name = "主校区" };
var building = new Building
{
CampusId = campus.Id,
Code = "LAB",
Name = "实验楼"
};
var classroom = new Classroom
{
BuildingId = building.Id,
Code = "LAB201",
Name = "实验室 201",
Capacity = 30
};
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
CollegeId = college.Id,
Code = "CS",
Name = "计算机科学与技术",
DegreeType = "工学"
};
var administrativeClass = new AdministrativeClass
{
MajorId = major.Id,
Code = "CS2099",
Name = "计科 2099",
Grade = 2099
};
var student = new Student
{
UserId = studentUser.Id,
AdministrativeClassId = administrativeClass.Id,
StudentNumber = "20990001",
Name = "实验学生",
EnrollmentYear = 2099,
EnrollmentDate = new DateOnly(2099, 9, 1)
};
var teacher = new Teacher
{
UserId = teacherUser.Id,
CollegeId = college.Id,
TeacherNumber = "T2099",
Name = "实验教师"
};
var term = new AcademicTerm
{
Code = "2099-1",
Name = "2099—2100 学年第一学期",
AcademicYear = "2099-2100",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2099, 9, 7),
EndDate = new DateOnly(2100, 1, 17),
IsCurrent = true
};
var course = new Course
{
CollegeId = college.Id,
Code = "CSLAB",
Name = "系统实验",
Credits = 2,
TotalHours = 32,
LectureHours = 16,
PracticeHours = 16,
Nature = CourseNature.Practice,
AssessmentMethod = AssessmentMethod.Assessment
};
var task = new TeachingTask
{
AcademicTermId = term.Id,
CourseId = course.Id,
TaskNumber = "2099-1-CSLAB-01",
Name = "系统实验教学班",
Capacity = 30,
Status = TeachingTaskStatus.Published,
Teachers =
[
new TeachingTaskTeacher
{
TeacherId = teacher.Id,
IsPrimary = true
}
],
Classes =
[
new TeachingTaskClass
{
AdministrativeClassId = administrativeClass.Id
}
]
};
db.AddRange(
admin,
studentUser,
teacherUser,
campus,
building,
classroom,
college,
major,
administrativeClass,
student,
teacher,
term,
course,
task);
await db.SaveChangesAsync();
return new ExperimentGradeFixture(
connection,
db,
term,
task,
student,
classroom,
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
Scope(studentUser, SystemRoles.Student, DataScope.Self));
}
public ExperimentGradesController ExperimentGrades(
ICurrentUserDataScope scope) => new(Db, scope);
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await Connection.DisposeAsync();
}
private static ApplicationUser User(
string userName,
string displayName) =>
new()
{
Id = Guid.NewGuid(),
UserName = userName,
NormalizedUserName = userName.ToUpperInvariant(),
DisplayName = displayName,
IsEnabled = true
};
private static ICurrentUserDataScope Scope(
ApplicationUser user,
string role,
DataScope dataScope) =>
new FixedScope(new CurrentUserScope(
user.Id,
user.DisplayName,
user.CollegeId,
dataScope,
new HashSet<string>([role])));
}
private sealed class FixedScope(CurrentUserScope current)
: ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = current;
}
}
+9 -4
View File
@@ -80,11 +80,16 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
? [{ ? [{
key: 'experiments', key: 'experiments',
label: isStudent.value ? '我的实验' : '实验管理', label: isStudent.value ? '我的实验' : '实验管理',
direct: true, items: [
items: [{ {
path: '/experiments', path: '/experiments',
label: isStudent.value ? '我的实验' : '实验管理', label: isStudent.value ? '实验安排' : '项目与场次',
}], },
{
path: '/experiment-grades',
label: '实验成绩',
},
],
} as NavigationGroup] } as NavigationGroup]
: []), : []),
{ {
+8
View File
@@ -200,6 +200,14 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'], roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
}, },
}, },
{
path: 'experiment-grades',
name: 'experiment-grades',
component: () => import('../views/ExperimentGradesView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student'],
},
},
{ {
path: 'course-selections', path: 'course-selections',
name: 'course-selections', name: 'course-selections',
+834
View File
@@ -0,0 +1,834 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Check,
EditPen,
Plus,
Promotion,
Refresh,
Setting,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import {
academicTermLabel,
academicTermOptionClass,
defaultAcademicTermId,
} from '../utils/academicTerms'
const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student') ?? false)
const loading = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const terms = ref<any[]>([])
const termId = ref('')
const statusFilter = ref('')
const projects = ref<any[]>([])
const studentResults = ref<any[]>([])
const detailDrawer = ref(false)
const detail = ref<any | null>(null)
const recordPage = ref(1)
const recordPageSize = ref(50)
const recordKeyword = ref('')
const schemeDialog = ref(false)
const editingScheme = ref(false)
const schemeProject = ref<any | null>(null)
const schemeForm = reactive({
contributionWeight: 1,
passScore: 60,
items: [] as Array<{ name: string; kind: string; weight: number }>,
})
const statusLabels: Record<string, string> = {
Draft: '录入中',
Submitted: '待审核',
Approved: '审核通过',
Published: '已发布',
Returned: '已退回',
}
const statusTypes: Record<string, string> = {
Draft: 'info',
Submitted: 'warning',
Approved: 'success',
Published: 'primary',
Returned: 'danger',
}
const participationLabels: Record<string, string> = {
Pending: '待登记',
Completed: '已完成',
Absent: '缺席',
Excused: '请假',
Makeup: '补做',
Exempt: '免做',
}
const kindLabels: Record<string, string> = {
Operation: '实验操作',
Report: '实验报告',
Result: '实验结果',
Defense: '答辩',
Attendance: '到场情况',
Safety: '安全规范',
Other: '其他',
}
const modeLabels: Record<string, string> = {
Centralized: '集中安排',
SelfScheduled: '自主预约',
}
const schemeWeightTotal = computed(() =>
schemeForm.items.reduce((sum, item) => sum + Number(item.weight || 0), 0),
)
const detailAverage = computed(() => {
const values = (detail.value?.records ?? [])
.map((record: any) => previewTotal(record))
.filter((score: number | null) => score != null) as number[]
if (!values.length) return '—'
return (values.reduce((sum, score) => sum + score, 0) / values.length).toFixed(1)
})
const detailPassed = computed(() =>
(detail.value?.records ?? []).filter((record: any) => {
const score = previewTotal(record)
return score != null && score >= Number(detail.value.passScore) && !record.safetyViolation
}).length,
)
function resetScheme() {
schemeForm.contributionWeight = 1
schemeForm.passScore = 60
schemeForm.items = [
{ name: '实验操作', kind: 'Operation', weight: 35 },
{ name: '实验结果', kind: 'Result', weight: 20 },
{ name: '实验报告', kind: 'Report', weight: 35 },
{ name: '到场与规范', kind: 'Attendance', weight: 10 },
]
}
function addSchemeItem() {
schemeForm.items.push({ name: '', kind: 'Other', weight: 0 })
}
function openCreate(project: any) {
editingScheme.value = false
schemeProject.value = project
resetScheme()
schemeDialog.value = true
}
function openSettings() {
if (!detail.value) return
editingScheme.value = true
schemeProject.value = {
id: detail.value.experimentProjectId,
name: detail.value.projectName,
sheet: { id: detail.value.id },
}
schemeForm.contributionWeight = Number(detail.value.contributionWeight)
schemeForm.passScore = Number(detail.value.passScore)
schemeForm.items = detail.value.items.map((item: any) => ({
name: item.name,
kind: item.kind,
weight: Number(item.weight),
}))
schemeDialog.value = true
}
async function saveScheme() {
if (!schemeProject.value) return
if (!schemeForm.items.length ||
schemeForm.items.some((item) => !item.name.trim() || Number(item.weight) <= 0) ||
schemeWeightTotal.value !== 100) {
ElMessage.warning('请完整填写评分项,且权重合计必须为 100%')
return
}
try {
const payload = {
contributionWeight: schemeForm.contributionWeight,
passScore: schemeForm.passScore,
items: schemeForm.items.map((item) => ({
name: item.name.trim(),
kind: item.kind,
weight: item.weight,
})),
}
if (editingScheme.value) {
await http.put(`/experiment-grades/sheets/${schemeProject.value.sheet.id}/settings`, payload)
ElMessage.success('实验评分方案已更新,原分项成绩已清空')
await loadDetail()
} else {
await http.post('/experiment-grades/sheets', {
experimentProjectId: schemeProject.value.id,
...payload,
})
ElMessage.success('实验成绩单已建立')
await load()
}
schemeDialog.value = false
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function load() {
loading.value = true
try {
if (isStudent.value) {
studentResults.value = (await http.get('/experiment-grades/mine')).data
} else {
projects.value = (await http.get('/experiment-grades/management', {
params: {
academicTermId: termId.value || undefined,
status: statusFilter.value || undefined,
},
})).data
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function openDetail(project: any) {
detailDrawer.value = true
detail.value = {
id: project.sheet.id,
items: [],
records: [],
recordTotal: 0,
}
recordPage.value = 1
recordKeyword.value = ''
await loadDetail()
}
async function loadDetail() {
if (!detail.value?.id) return
detailLoading.value = true
try {
const response = (await http.get(`/experiment-grades/sheets/${detail.value.id}`, {
params: {
recordPage: recordPage.value,
recordPageSize: recordPageSize.value,
studentKeyword: recordKeyword.value.trim() || undefined,
},
})).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 itemScore(record: any, itemId: string) {
return record.itemScores?.find((score: any) =>
score.experimentGradeItemId === itemId)
}
function previewTotal(record: any): number | null {
if (record.safetyViolation || record.participationStatus === 'Absent') return 0
if (!['Completed', 'Makeup'].includes(record.participationStatus)) return null
let total = 0
for (const item of detail.value?.items ?? []) {
const score = itemScore(record, item.id)?.score
if (score == null || score === '') return null
total += Number(score) * Number(item.weight) / 100
}
return Math.round(total * 10) / 10
}
async function saveRecords() {
if (!detail.value) return
saving.value = true
try {
await http.put(`/experiment-grades/sheets/${detail.value.id}/records`, {
records: detail.value.records.map((record: any) => ({
id: record.id,
participationStatus: record.participationStatus,
safetyViolation: record.safetyViolation,
attemptNumber: record.attemptNumber,
submissionReference: record.submissionReference,
submittedAt: record.submittedAt,
isLate: record.isLate,
teacherComment: record.teacherComment,
itemScores: record.itemScores.map((score: any) => ({
experimentGradeItemId: score.experimentGradeItemId,
score: score.score,
comment: score.comment,
})),
})),
})
ElMessage.success('本页实验成绩已保存')
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
saving.value = false
}
}
async function syncParticipants() {
if (!detail.value) return
try {
const result = (await http.post(
`/experiment-grades/sheets/${detail.value.id}/sync-participants`,
)).data
ElMessage.success(
`名单已同步:新增 ${result.added} 人,移除 ${result.removed} 人,当前 ${result.total}`,
)
recordPage.value = 1
await loadDetail()
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function submitSheet() {
try {
await ElMessageBox.confirm(
'提交后教师不能继续修改,学院审核退回后方可重新录入。',
'提交实验成绩审核',
{ confirmButtonText: '确认提交', cancelButtonText: '继续检查', type: 'warning' },
)
await http.post(`/experiment-grades/sheets/${detail.value.id}/submit`)
ElMessage.success('实验成绩已提交审核')
await loadDetail()
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function approveSheet() {
try {
await http.post(`/experiment-grades/sheets/${detail.value.id}/approve`)
ElMessage.success('实验成绩审核通过')
await loadDetail()
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function returnSheet() {
try {
const { value } = await ElMessageBox.prompt(
'请填写教师需要修正的具体内容。',
'退回实验成绩',
{
confirmButtonText: '确认退回',
cancelButtonText: '取消',
inputType: 'textarea',
inputValidator: (value) => value.trim() ? true : '必须填写退回意见',
},
)
await http.post(`/experiment-grades/sheets/${detail.value.id}/return`, {
comment: value,
})
ElMessage.success('实验成绩已退回')
await loadDetail()
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function publishSheet() {
try {
await ElMessageBox.confirm(
'发布后学生可查看各评分项和实验总评,课程成绩也可以导入这份结果。',
'发布实验成绩',
{ confirmButtonText: '确认发布', cancelButtonText: '暂不发布', type: 'warning' },
)
await http.post(`/experiment-grades/sheets/${detail.value.id}/publish`)
ElMessage.success('实验成绩已向学生发布')
await loadDetail()
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
function scoreTone(result: any) {
if (result.totalScore == null) return 'is-pending'
return result.isPassed ? 'is-passed' : 'is-failed'
}
onMounted(async () => {
if (!isStudent.value) {
try {
terms.value = (await http.get('/base-data/terms')).data
termId.value = defaultAcademicTermId(terms.value) ?? ''
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
await load()
})
</script>
<template>
<main class="experiment-grade-page">
<section class="page-intro grade-intro">
<div>
<span class="section-kicker">LAB ASSESSMENT LEDGER</span>
<h2>{{ isStudent ? '我的实验成绩' : '实验成绩管理' }}</h2>
<p v-if="isStudent">实验成绩独立记录操作结果报告和到场情况不与课程总评混在一起</p>
<p v-else>按实验项目建立独立评分方案审核发布后可选择汇总到课程成绩单</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<section class="assessment-flow" aria-label="实验成绩流程">
<div><span>PROJECT</span><b>实验项目</b><small>集中安排 / 自主预约</small></div>
<i></i>
<div><span>RUBRIC</span><b>独立评分</b><small>操作 · 结果 · 报告 · 规范</small></div>
<i></i>
<div><span>PUBLISH</span><b>审核发布</b><small>学生查看 / 课程可选导入</small></div>
</section>
<template v-if="isStudent">
<section v-loading="loading" class="student-result-grid">
<article
v-for="result in studentResults"
:key="result.id"
class="student-result-card"
:class="scoreTone(result)"
>
<header>
<div>
<span>{{ result.projectCode }} · {{ modeLabels[result.arrangementMode] }}</span>
<h3>{{ result.projectName }}</h3>
<p>{{ result.courseCode }} · {{ result.courseName }} · {{ result.termName }}</p>
</div>
<div class="score-seal">
<strong>{{ result.totalScore ?? '—' }}</strong>
<small>{{ result.totalScore == null ? participationLabels[result.participationStatus] : result.isPassed ? '通过' : '未通过' }}</small>
</div>
</header>
<div class="result-facts">
<span>参与状态<b>{{ participationLabels[result.participationStatus] }}</b></span>
<span>合格线<b>{{ result.passScore }}</b></span>
<span>实验次数<b>{{ result.attemptNumber }}</b></span>
<span>提交情况<b>{{ result.isLate ? '迟交' : result.submittedAt ? '已提交' : '未登记' }}</b></span>
</div>
<div class="result-rubric">
<div v-for="item in result.items" :key="item.experimentGradeItemId">
<span>{{ item.name }} · {{ item.weight }}%</span>
<b>{{ item.score ?? '—' }}</b>
<small v-if="item.comment">{{ item.comment }}</small>
</div>
</div>
<p v-if="result.safetyViolation" class="safety-alert">已登记安全违规本次实验按规则判定为 0 </p>
<p v-if="result.teacherComment" class="teacher-comment">{{ result.teacherComment }}</p>
</article>
<el-empty v-if="!loading && !studentResults.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="academicTermLabel(term)"
:value="term.id"
:class="academicTermOptionClass(term)"
/>
</el-select>
<el-select v-model="statusFilter" clearable placeholder="全部成绩状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<span>每个实验项目独立建单课程只读取已发布的汇总快照</span>
</section>
<section v-loading="loading" class="grade-project-list">
<article v-for="project in projects" :key="project.id" class="grade-project-card">
<div class="project-mark">
<span>{{ project.code }}</span>
<b>{{ modeLabels[project.arrangementMode] }}</b>
</div>
<div class="project-summary">
<h3>{{ project.name }}</h3>
<p>{{ project.courseCode }} · {{ project.courseName }}</p>
<small>{{ project.taskNumber }} · {{ project.termName }} · {{ project.teacherNames.join('、') || '教师待定' }}</small>
</div>
<template v-if="project.sheet">
<div class="sheet-progress">
<el-tag :type="statusTypes[project.sheet.status] as any" effect="plain">
{{ statusLabels[project.sheet.status] }}
</el-tag>
<span><b>{{ project.sheet.scoredCount }}</b> / {{ project.sheet.studentCount }} 已计分</span>
<span>评分项 {{ project.sheet.itemCount }} · 合格线 {{ project.sheet.passScore }}</span>
</div>
<el-button type="primary" plain :icon="EditPen" @click="openDetail(project)">
打开成绩单
</el-button>
</template>
<template v-else>
<div class="sheet-empty">
<b>尚未建立实验成绩单</b>
<span>建立时会按集中名单或有效预约生成评分对象</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate(project)">
建立成绩单
</el-button>
</template>
</article>
<el-empty v-if="!loading && !projects.length" description="当前筛选条件下没有可评分实验项目" />
</section>
</template>
<el-dialog
v-model="schemeDialog"
:title="`${editingScheme ? '调整评分方案' : '建立实验成绩单'} · ${schemeProject?.name ?? ''}`"
width="720px"
top="5vh"
>
<div class="scheme-form">
<el-alert
v-if="editingScheme"
title="调整评分方案会清空原有分项得分,参与状态和教师评语保留。"
type="warning"
show-icon
:closable="false"
/>
<div class="scheme-basics">
<label>
<span>项目贡献权重</span>
<el-input-number v-model="schemeForm.contributionWeight" :min="0.1" :max="100" :step="0.5" />
<small>多个实验项目汇总到课程时按此相对权重计算</small>
</label>
<label>
<span>实验合格线</span>
<el-input-number v-model="schemeForm.passScore" :min="0" :max="100" :step="0.5" />
<small>仅决定该实验是否通过不直接计算课程绩点</small>
</label>
</div>
<div class="rubric-editor">
<header>
<div><span>RUBRIC</span><b>实验评分项</b></div>
<strong :class="{ invalid: schemeWeightTotal !== 100 }">合计 {{ schemeWeightTotal }}%</strong>
</header>
<div v-for="(item, index) in schemeForm.items" :key="index" class="rubric-row">
<el-input v-model="item.name" maxlength="60" placeholder="评分项名称" />
<el-select v-model="item.kind">
<el-option v-for="(label, value) in kindLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-input-number v-model="item.weight" :min="0" :max="100" :controls="false" />
<span>%</span>
<el-button text type="danger" :disabled="schemeForm.items.length === 1" @click="schemeForm.items.splice(index, 1)">
删除
</el-button>
</div>
<el-button text type="primary" :icon="Plus" @click="addSchemeItem">增加评分项</el-button>
</div>
</div>
<template #footer>
<el-button @click="schemeDialog = false">取消</el-button>
<el-button type="primary" @click="saveScheme">{{ editingScheme ? '保存并重置分项' : '建立成绩单' }}</el-button>
</template>
</el-dialog>
<el-drawer
v-model="detailDrawer"
title="实验评分工作台"
size="94%"
class="experiment-grade-drawer"
>
<div v-if="detail" v-loading="detailLoading" class="grade-workbench">
<header class="workbench-head">
<div>
<span>{{ detail.projectCode }} · {{ modeLabels[detail.arrangementMode] }}</span>
<h3>{{ detail.projectName }}</h3>
<p>{{ detail.courseCode }} · {{ detail.courseName }} · {{ detail.termName }}</p>
</div>
<div class="workbench-status">
<el-tag :type="statusTypes[detail.status] as any" effect="dark">{{ statusLabels[detail.status] }}</el-tag>
<small>项目贡献权重 {{ detail.contributionWeight }} · 合格线 {{ detail.passScore }}</small>
</div>
</header>
<section class="workbench-metrics">
<div><span>学生数</span><b>{{ detail.recordTotal }}</b></div>
<div><span>本页平均</span><b>{{ detailAverage }}</b></div>
<div><span>本页通过</span><b>{{ detailPassed }}</b></div>
<div><span>评分项</span><b>{{ detail.items.length }}</b></div>
</section>
<div class="record-toolbar">
<el-input
v-model="recordKeyword"
clearable
placeholder="搜索学号、姓名或班级"
@keyup.enter="recordPage = 1; loadDetail()"
@clear="recordPage = 1; loadDetail()"
/>
<el-button @click="recordPage = 1; loadDetail()">查询</el-button>
<el-button v-if="detail.canEdit" :icon="Refresh" @click="syncParticipants">同步参与名单</el-button>
<el-button v-if="detail.canEdit" :icon="Setting" @click="openSettings">评分方案</el-button>
</div>
<el-table :data="detail.records" row-key="id" border class="grade-record-table">
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="record-evidence">
<label>
<span>提交物或报告地址</span>
<el-input v-if="detail.canEdit" v-model="row.submissionReference" maxlength="500" />
<b v-else>{{ row.submissionReference || '未登记' }}</b>
</label>
<label>
<span>提交时间</span>
<el-date-picker
v-if="detail.canEdit"
v-model="row.submittedAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
placeholder="未登记"
/>
<b v-else>{{ row.submittedAt ? new Date(row.submittedAt).toLocaleString('zh-CN') : '未登记' }}</b>
</label>
<label>
<span>迟交</span>
<el-switch v-if="detail.canEdit" v-model="row.isLate" />
<b v-else>{{ row.isLate ? '' : '' }}</b>
</label>
<label class="comment-field">
<span>教师总评</span>
<el-input v-if="detail.canEdit" v-model="row.teacherComment" type="textarea" maxlength="500" show-word-limit />
<b v-else>{{ row.teacherComment || '' }}</b>
</label>
<label
v-for="item in detail.items"
:key="item.id"
class="comment-field"
>
<span>{{ item.name }}评语</span>
<el-input
v-if="detail.canEdit"
:model-value="itemScore(row, item.id)?.comment"
maxlength="300"
@update:model-value="(value: string) => { const target = itemScore(row, item.id); if (target) target.comment = value }"
/>
<b v-else>{{ itemScore(row, item.id)?.comment || '' }}</b>
</label>
</div>
</template>
</el-table-column>
<el-table-column label="学生" fixed min-width="180">
<template #default="{ row }">
<b>{{ row.name }}</b>
<small class="student-line">{{ row.studentNumber }} · {{ row.className }}</small>
</template>
</el-table-column>
<el-table-column label="参与状态" width="125">
<template #default="{ row }">
<el-select v-if="detail.canEdit" v-model="row.participationStatus" size="small">
<el-option v-for="(label, value) in participationLabels" :key="value" :label="label" :value="value" />
</el-select>
<span v-else>{{ participationLabels[row.participationStatus] }}</span>
</template>
</el-table-column>
<el-table-column
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
width="125"
>
<template #default="{ row }">
<el-input-number
v-if="detail.canEdit && ['Completed', 'Makeup'].includes(row.participationStatus)"
:model-value="itemScore(row, item.id)?.score"
:min="0"
:max="100"
:step="0.5"
:controls="false"
size="small"
@update:model-value="(value: number | undefined) => { const target = itemScore(row, item.id); if (target) target.score = value }"
/>
<span v-else>{{ itemScore(row, item.id)?.score ?? '' }}</span>
</template>
</el-table-column>
<el-table-column label="安全违规" width="105">
<template #default="{ row }">
<el-switch v-if="detail.canEdit" v-model="row.safetyViolation" />
<el-tag v-else-if="row.safetyViolation" type="danger" size="small"></el-tag>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="次数" width="90">
<template #default="{ row }">
<el-input-number v-if="detail.canEdit" v-model="row.attemptNumber" :min="1" :max="20" :controls="false" size="small" />
<span v-else>{{ row.attemptNumber }}</span>
</template>
</el-table-column>
<el-table-column label="实验总评" fixed="right" width="105">
<template #default="{ row }">
<b class="record-total" :class="{ failed: previewTotal(row) != null && previewTotal(row)! < detail.passScore }">
{{ previewTotal(row) ?? '—' }}
</b>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="recordPage"
v-model:page-size="recordPageSize"
:total="detail.recordTotal"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadDetail"
@size-change="recordPage = 1; loadDetail()"
/>
<footer class="workbench-actions">
<div>
<b v-if="detail.reviewComment">审核意见{{ detail.reviewComment }}</b>
<span>开课学院{{ detail.courseCollegeName }}</span>
</div>
<div>
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @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="returnSheet">退回修改</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>
</div>
</footer>
</div>
</el-drawer>
</main>
</template>
<style scoped>
.experiment-grade-page { --grade-blue: #245d82; --grade-teal: #168276; --grade-ink: #173041; display: grid; gap: 18px; }
.grade-intro { align-items: flex-start; }
.assessment-flow { display: grid; grid-template-columns: 1fr 48px 1fr 48px 1fr; align-items: stretch; border: 1px solid #d8e4e7; background: #fff; }
.assessment-flow > div { padding: 15px 18px; display: grid; gap: 4px; border-top: 4px solid var(--grade-blue); }
.assessment-flow > div:nth-of-type(2) { border-top-color: #4c778f; }
.assessment-flow > div:nth-of-type(3) { border-top-color: var(--grade-teal); }
.assessment-flow > div span { color: #66808c; font: 700 10px/1.2 Consolas, monospace; letter-spacing: .08em; }
.assessment-flow > div b { color: var(--grade-ink); font-size: 15px; }
.assessment-flow > div small { color: var(--muted); }
.assessment-flow > i { display: grid; place-items: center; color: #8aa0aa; font-style: normal; background: #f4f7f8; }
.grade-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.grade-toolbar .el-select { width: 220px; }
.grade-toolbar > span { margin-left: auto; color: var(--muted); font-size: 12px; }
.grade-project-list { display: grid; gap: 12px; min-height: 180px; }
.grade-project-card { min-width: 0; padding: 16px 18px; display: grid; grid-template-columns: 130px minmax(220px, 1.4fr) minmax(220px, 1fr) auto; align-items: center; gap: 18px; border: 1px solid #d9e4e8; border-left: 5px solid var(--grade-blue); background: #fff; box-shadow: 0 6px 18px rgb(30 68 86 / 5%); }
.project-mark { display: grid; gap: 7px; }
.project-mark span { overflow: hidden; color: #607b88; font: 700 10px/1.3 Consolas, monospace; text-overflow: ellipsis; }
.project-mark b { color: var(--grade-blue); font-size: 12px; }
.project-summary { min-width: 0; }
.project-summary h3 { margin: 0 0 5px; color: var(--grade-ink); font-size: 17px; }
.project-summary p { margin: 0 0 4px; color: #365f73; font-size: 13px; }
.project-summary small { display: block; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.sheet-progress, .sheet-empty { display: grid; gap: 5px; color: #607681; font-size: 12px; }
.sheet-progress b { color: var(--grade-teal); }
.sheet-empty b { color: #5d7480; }
.student-result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 15px; min-height: 180px; }
.student-result-card { overflow: hidden; border: 1px solid #d8e4e6; border-top: 5px solid #80939b; background: #fff; }
.student-result-card.is-passed { border-top-color: var(--grade-teal); }
.student-result-card.is-failed { border-top-color: #b5543d; }
.student-result-card > header { padding: 18px; display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e4eaed; }
.student-result-card header span { color: #607d89; font: 700 10px/1.3 Consolas, monospace; }
.student-result-card h3 { margin: 5px 0; color: var(--grade-ink); font-size: 18px; }
.student-result-card header p { margin: 0; color: var(--muted); font-size: 12px; }
.score-seal { width: 72px; flex: 0 0 72px; display: grid; place-content: center; border-left: 1px solid #dce6e8; text-align: center; }
.score-seal strong { color: var(--grade-teal); font: 750 28px/1 Consolas, monospace; }
.is-failed .score-seal strong { color: #b5543d; }
.score-seal small { margin-top: 5px; color: var(--muted); }
.result-facts { padding: 12px 18px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; background: #f5f8f8; }
.result-facts span { display: grid; gap: 3px; color: #758991; font-size: 10px; }
.result-facts b { color: #365762; font-size: 12px; }
.result-rubric { padding: 14px 18px; display: grid; gap: 8px; }
.result-rubric > div { padding: 8px 10px; display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; border-left: 3px solid #9cb6bb; background: #f7f9f9; }
.result-rubric span { color: #58727c; font-size: 12px; }
.result-rubric b { color: var(--grade-ink); }
.result-rubric small { grid-column: 1 / -1; color: var(--muted); }
.safety-alert, .teacher-comment { margin: 0 18px 15px; padding: 10px 12px; font-size: 12px; }
.safety-alert { color: #8e382b; background: #fff0ed; }
.teacher-comment { color: #49686e; background: #eef6f4; }
.scheme-form { display: grid; gap: 18px; }
.scheme-basics { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.scheme-basics label { padding: 14px; display: grid; gap: 8px; border: 1px solid #dce5e8; background: #f8fafb; }
.scheme-basics label > span { color: var(--grade-ink); font-weight: 700; }
.scheme-basics label small { color: var(--muted); line-height: 1.5; }
.rubric-editor { border: 1px solid #dce5e8; }
.rubric-editor header { padding: 12px 14px; display: flex; justify-content: space-between; border-bottom: 1px solid #dce5e8; background: #f3f7f7; }
.rubric-editor header div { display: flex; align-items: baseline; gap: 10px; }
.rubric-editor header span { color: var(--grade-teal); font: 700 10px/1 Consolas, monospace; }
.rubric-editor header b { color: var(--grade-ink); }
.rubric-editor header strong { color: var(--grade-teal); }
.rubric-editor header strong.invalid { color: #b5543d; }
.rubric-row { padding: 9px 12px; display: grid; grid-template-columns: 1.3fr 150px 100px 20px auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f2; }
.rubric-editor > .el-button { margin: 6px 10px; }
:global(.experiment-grade-drawer .el-drawer__body) { padding: 0; }
.grade-workbench { min-height: 100%; display: grid; align-content: start; gap: 15px; background: #f4f7f8; }
.workbench-head { padding: 18px 22px; display: flex; justify-content: space-between; gap: 20px; border-bottom: 1px solid #d9e3e6; background: #fff; }
.workbench-head span { color: #5d7c89; font: 700 10px/1.3 Consolas, monospace; }
.workbench-head h3 { margin: 5px 0; color: var(--grade-ink); font-size: 21px; }
.workbench-head p { margin: 0; color: var(--muted); }
.workbench-status { display: grid; justify-items: end; align-content: center; gap: 7px; }
.workbench-status small { color: var(--muted); }
.workbench-metrics { margin: 0 18px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid #d9e4e6; background: #fff; }
.workbench-metrics div { padding: 12px 16px; display: grid; gap: 3px; border-right: 1px solid #e2e9eb; }
.workbench-metrics div:last-child { border-right: 0; }
.workbench-metrics span { color: var(--muted); font-size: 11px; }
.workbench-metrics b { color: var(--grade-ink); font-size: 20px; }
.record-toolbar { margin: 0 18px; display: flex; gap: 8px; }
.record-toolbar .el-input { max-width: 320px; }
.grade-record-table { width: calc(100% - 36px); margin: 0 18px; }
.student-line { display: block; margin-top: 3px; color: var(--muted); }
.record-total { color: var(--grade-teal); font: 750 16px/1 Consolas, monospace; }
.record-total.failed { color: #b5543d; }
.record-evidence { padding: 14px 18px; display: grid; grid-template-columns: repeat(3, minmax(180px, 1fr)); gap: 12px; background: #f7faf9; }
.record-evidence label { display: grid; align-content: start; gap: 6px; }
.record-evidence label > span { color: #536f78; font-size: 11px; font-weight: 700; }
.record-evidence label > b { color: #304e58; font-size: 12px; font-weight: 500; }
.record-evidence .comment-field { grid-column: span 2; }
.grade-workbench > .el-pagination { margin: 0 18px; justify-content: flex-end; }
.workbench-actions { padding: 15px 20px; display: flex; justify-content: space-between; gap: 14px; border-top: 1px solid #d9e3e6; background: #fff; }
.workbench-actions > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.workbench-actions b { color: #a24f36; font-size: 12px; }
.workbench-actions span { color: var(--muted); font-size: 12px; }
@media (max-width: 980px) {
.grade-project-card { grid-template-columns: 110px minmax(0, 1fr) auto; }
.sheet-progress, .sheet-empty { grid-column: 2; }
.grade-project-card > .el-button { grid-column: 3; grid-row: 1 / span 2; }
}
@media (max-width: 700px) {
.assessment-flow { grid-template-columns: 1fr; }
.assessment-flow > i { display: none; }
.grade-toolbar { align-items: stretch; flex-direction: column; }
.grade-toolbar .el-select { width: 100%; }
.grade-toolbar > span { margin-left: 0; }
.grade-project-card { grid-template-columns: 1fr; }
.sheet-progress, .sheet-empty, .grade-project-card > .el-button { grid-column: 1; grid-row: auto; width: 100%; }
.student-result-grid { grid-template-columns: 1fr; }
.student-result-card > header { align-items: stretch; flex-direction: column; }
.score-seal { width: auto; padding-top: 12px; grid-template-columns: auto auto; gap: 8px; border-top: 1px solid #dce6e8; border-left: 0; }
.result-facts { grid-template-columns: repeat(2, 1fr); }
.scheme-basics { grid-template-columns: 1fr; }
.rubric-row { grid-template-columns: 1fr 120px 80px 20px; }
.rubric-row .el-button { grid-column: 1 / -1; justify-self: end; }
.workbench-head, .workbench-actions { align-items: stretch; flex-direction: column; }
.workbench-status { justify-items: start; }
.workbench-metrics { grid-template-columns: repeat(2, 1fr); }
.record-toolbar { flex-wrap: wrap; }
.record-toolbar .el-input { max-width: none; flex: 1 1 100%; }
.record-evidence { grid-template-columns: 1fr; }
.record-evidence .comment-field { grid-column: 1; }
}
</style>
+58 -4
View File
@@ -39,6 +39,7 @@ const taskTotal = ref(0)
const recordKeyword = ref('') const recordKeyword = ref('')
const recordPage = ref(1) const recordPage = ref(1)
const recordPageSize = 50 const recordPageSize = 50
const experimentGradeItemId = ref('')
const createForm = reactive({ const createForm = reactive({
regularWeight: 30, regularWeight: 30,
finalWeight: 70, finalWeight: 70,
@@ -186,6 +187,12 @@ async function loadDetail() {
detail.value.canEdit = response.canEdit detail.value.canEdit = response.canEdit
detail.value.canReview = response.canReview detail.value.canReview = response.canReview
detail.value.canPublish = response.canPublish detail.value.canPublish = response.canPublish
if (!detail.value.items?.some((item: any) => item.id === experimentGradeItemId.value)) {
experimentGradeItemId.value =
detail.value.items?.find((item: any) => item.name.includes('实验'))?.id
?? detail.value.items?.[0]?.id
?? ''
}
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
} finally { } finally {
@@ -254,6 +261,29 @@ async function saveRecords() {
} }
} }
async function importExperimentScores() {
if (!experimentGradeItemId.value) {
ElMessage.warning('请先选择用于承接实验成绩的课程成绩项。')
return
}
try {
const result = (await http.post(
`/grades/sheets/${detail.value.id}/import-experiment-scores`,
{ gradeItemId: experimentGradeItemId.value },
)).data
const missingHint = result.missingCount > 0
? `${result.missingCount} 名学生因实验成绩尚未完整发布而保持空白`
: ''
ElMessage.success(
`已从 ${result.publishedProjectCount} 个实验项目导入 ${result.importedCount} 名学生的成绩快照${missingHint}`,
)
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function submitSheet() { async function submitSheet() {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -583,17 +613,20 @@ onMounted(async () => {
<el-table-column <el-table-column
v-for="item in detail.items" v-for="item in detail.items"
:key="item.id" :key="item.id"
:label="`${item.name} ${item.weight}%`" :label="`${item.name} ${item.weight}%${item.sourceType === 'ExperimentSummary' ? ' · 实验快照' : ''}`"
width="110" :width="item.sourceType === 'ExperimentSummary' ? 150 : 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' && item.sourceType !== 'ExperimentSummary'"
:model-value="itemScore(row, item.id)?.score" :model-value="itemScore(row, item.id)?.score"
@update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }" @update:model-value="(val: number | undefined) => { const found = itemScore(row, item.id); if (found) found.score = val }"
:min="0" :max="100" :controls="false" size="small" :min="0" :max="100" :controls="false" size="small"
/> />
<span v-else>{{ itemScore(row, item.id)?.score ?? '' }}</span> <span v-else class="item-score-readonly">
{{ itemScore(row, item.id)?.score ?? '—' }}
<small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small>
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110"> <el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
@@ -654,6 +687,21 @@ onMounted(async () => {
</small> </small>
</div> </div>
<div class="grade-actions-buttons"> <div class="grade-actions-buttons">
<div v-if="detail.canEdit && detail.items?.length" class="experiment-import">
<el-select
v-model="experimentGradeItemId"
placeholder="选择课程成绩项"
aria-label="实验成绩导入目标"
>
<el-option
v-for="item in detail.items"
:key="item.id"
:label="`${item.name} ${item.weight}%`"
:value="item.id"
/>
</el-select>
<el-button :icon="Refresh" @click="importExperimentScores">导入实验成绩</el-button>
</div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button> <el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" @click="chooseImportFile">Excel导入</el-button> <el-button v-if="detail.canEdit" :icon="Upload" @click="chooseImportFile">Excel导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button> <el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
@@ -765,6 +813,10 @@ onMounted(async () => {
.workflow-status .college-hint { color: #6b4e16; font-weight: 600; } .workflow-status .college-hint { color: #6b4e16; font-weight: 600; }
.grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; } .grade-actions { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-top: 1px solid #e4e7ed; background: #fafbfc; }
.grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; } .grade-actions-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.experiment-import { display: flex; gap: 6px; align-items: center; padding-right: 8px; border-right: 1px solid var(--line); }
.experiment-import .el-select { width: 165px; }
.item-score-readonly { display: inline-flex; flex-direction: column; align-items: center; line-height: 1.1; }
.item-score-readonly small { margin-top: 3px; color: #2d8975; font-size: 10px; font-weight: 650; }
.grade-toolbar > .el-input { width: min(320px, 100%); } .grade-toolbar > .el-input { width: min(320px, 100%); }
.grade-task-pagination { justify-content: center; padding: 14px 8px; border-top: 1px solid var(--line); } .grade-task-pagination { justify-content: center; padding: 14px 8px; border-top: 1px solid var(--line); }
.grade-record-toolbar { .grade-record-toolbar {
@@ -777,5 +829,7 @@ onMounted(async () => {
@media (max-width: 760px) { @media (max-width: 760px) {
.grade-record-toolbar > span { width: 100%; margin-left: 0; } .grade-record-toolbar > span { width: 100%; margin-left: 0; }
.grade-record-pagination { justify-content: center; } .grade-record-pagination { justify-content: center; }
.experiment-import { width: 100%; padding-right: 0; padding-bottom: 8px; border-right: 0; border-bottom: 1px solid var(--line); }
.experiment-import .el-select { flex: 1; width: auto; }
} }
</style> </style>