已按“实验成绩独立管理”完成修改。
每个实验项目独立建成绩单,支持评分项、参与状态、补做次数、安全违规、报告地址和教师评语。 流程为:录入 → 学院审核 → 教务发布;学生只能查看已发布成绩。 自主预约实验支持同步有效预约名单,已评分记录不会被误删。 课程成绩可将已发布实验成绩汇总为快照导入指定分项;导入后锁定,不能手工篡改。 实验项目成绩不完整时,对应课程分项保持空白,不按 0 分处理。 一级“实验管理”菜单下增加“项目与场次”“实验成绩”;学生端增加“实验安排”“实验成绩”。 已生成 MySQL 正式迁移及 SQLite 开发迁移,尚未操作生产数据库。 界面采用“实验项目 → 独立评分 → 审核发布”工作台设计。
This commit is contained in:
@@ -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);
|
||||||
@@ -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,8 +432,19 @@ 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(
|
||||||
existing.Score = scoreEntry.Score;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+5905
File diff suppressed because it is too large
Load Diff
+208
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+264
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user