实验成绩 Excel 流程

This commit is contained in:
2026-08-10 16:52:03 +08:00 Unverified
parent 720ff6ef1d
commit 031459ac80
3 changed files with 357 additions and 0 deletions
@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Data; using System.Data;
using System.Globalization;
using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity; using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Excel;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Teaching; using Jiaowu.Api.Infrastructure.Teaching;
@@ -574,6 +576,166 @@ public sealed class ExperimentGradesController(
return NoContent(); return NoContent();
} }
[HttpGet("sheets/{id:guid}/template")]
[Authorize(Roles = Managers)]
public async Task<IActionResult> DownloadTemplate(
Guid id,
CancellationToken cancellationToken)
{
var sheet = await LoadEditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
var headers = ExperimentImportHeaders(sheet.Items);
var rows = sheet.Records
.OrderBy(record => record.Student!.StudentNumber)
.Select(record =>
{
var values = new List<object?>
{
record.Student!.StudentNumber,
record.Student.Name,
record.Student.AdministrativeClass!.Name,
ParticipationLabel(record.ParticipationStatus)
};
foreach (var item in sheet.Items.OrderBy(item => item.SortOrder))
values.Add(record.ItemScores.FirstOrDefault(score =>
score.ExperimentGradeItemId == item.Id)?.Score);
values.Add(record.SafetyViolation);
values.Add(record.AttemptNumber);
values.Add(null);
values.Add(record.TeacherComment);
return (IReadOnlyList<object?>)values;
})
.ToList();
var itemCount = sheet.Items.Count;
var totalColumn = 7 + itemCount;
var scoreColumns = Enumerable.Range(5, itemCount).Append(totalColumn);
var instructions = new List<string>
{
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配本实验成绩单的学生。",
"参与状态填写:待登记、已完成、缺席、请假、补做 或 免做。",
"评分项填写 0—100 的数值,留空表示暂未录入。",
"安全违规填写 是 或 否;实验次数填写 1—20。",
"实验总评(自动计算)仅供 Excel 预览;上传时系统会按评分项、参与状态和安全违规重新计算。",
$"本实验共 {itemCount} 个评分项:{string.Join("", sheet.Items.OrderBy(item => item.SortOrder).Select(item => item.Name))}。"
};
var bytes = ExcelWorkbookHelper.Create(
"实验成绩导入", headers, rows, instructions,
(worksheet, rowNumber) =>
{
var itemReferences = Enumerable.Range(5, itemCount)
.Select(column => $"{ColumnLetter(column)}{rowNumber}")
.ToArray();
var weightedExpression = string.Join("+", sheet.Items
.OrderBy(item => item.SortOrder)
.Select((item, index) =>
$"{ColumnLetter(index + 5)}{rowNumber}*{item.Weight.ToString(CultureInfo.InvariantCulture)}/100"));
var statusReference = $"D{rowNumber}";
var safetyReference = $"{ColumnLetter(5 + itemCount)}{rowNumber}";
worksheet.Cell(rowNumber, totalColumn).FormulaA1 =
$"=IF(OR({statusReference}=\"\",{safetyReference}=\"是\"),0,IF(OR({statusReference}=\"待登记\",{statusReference}=\"请假\",{statusReference}=\"免做\"),\"\",IF(COUNT({string.Join(",", itemReferences)})={itemCount},ROUND({weightedExpression},1),\"\")))";
var totalCell = worksheet.Cell(rowNumber, totalColumn);
totalCell.Style.NumberFormat.Format = "0.0";
totalCell.Style.Font.Bold = true;
totalCell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB");
foreach (var column in scoreColumns)
{
var format = worksheet.Range(rowNumber, column, rowNumber, column)
.AddConditionalFormat().WhenLessThan(60);
format.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#FDECEC");
format.Font.FontColor = ClosedXML.Excel.XLColor.FromHtml("#B42318");
}
});
return File(bytes, ExcelWorkbookHelper.ContentType,
$"实验成绩导入模板-{sheet.ExperimentProject!.Code}.xlsx");
}
[HttpPost("sheets/{id:guid}/import")]
[Authorize(Roles = Managers)]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<ActionResult> ImportRecords(
Guid id,
IFormFile file,
CancellationToken cancellationToken)
{
var sheet = await LoadEditableSheetAsync(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 headers = ExperimentImportHeaders(sheet.Items);
IReadOnlyList<ExcelRow> rows;
try
{
rows = await ExcelWorkbookHelper.ReadAsync(file,
headers.Where(header => header != "实验总评(自动计算)").ToArray(),
cancellationToken);
}
catch (InvalidDataException exception)
{
return ValidationProblem(exception.Message);
}
if (rows.Count == 0) return ValidationProblem("Excel 中没有可导入的实验成绩数据。");
var records = sheet.Records.ToDictionary(record => record.Student!.StudentNumber,
StringComparer.OrdinalIgnoreCase);
var items = sheet.Items.OrderBy(item => item.SortOrder).ToList();
var errors = new List<string>();
var updated = 0;
foreach (var row in rows)
{
var studentNumber = row["学号"];
if (string.IsNullOrWhiteSpace(studentNumber))
{
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
continue;
}
if (!records.TryGetValue(studentNumber, out var record))
{
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本实验成绩单中。");
continue;
}
if (!TryParseParticipationStatus(row, out var participationStatus, out var participationError))
{
errors.Add($"第 {row.RowNumber} 行:{participationError}");
continue;
}
if (!TryParseYesNo(row["安全违规"], out var safetyViolation))
{
errors.Add($"第 {row.RowNumber} 行:“安全违规”请填写是或否。");
continue;
}
if (!int.TryParse(row["实验次数"], out var attemptNumber) || attemptNumber is < 1 or > 20)
{
errors.Add($"第 {row.RowNumber} 行:“实验次数”请填写 1—20 的整数。");
continue;
}
var scores = new List<decimal?>();
foreach (var item in items)
{
var score = ParseOptionalDecimal(row, item.Name, 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains($"第 {row.RowNumber} 行")) break;
scores.Add(score);
}
if (scores.Count != items.Count) continue;
record.ParticipationStatus = participationStatus;
record.SafetyViolation = safetyViolation;
record.AttemptNumber = attemptNumber;
record.TeacherComment = Normalize(row["教师评语"]);
var scoreMap = record.ItemScores.ToDictionary(score => score.ExperimentGradeItemId);
for (var index = 0; index < items.Count; index++)
scoreMap[items[index].Id].Score = scores[index];
Recalculate(sheet, record);
updated++;
}
if (errors.Count > 0) return ImportValidationProblem(errors);
await db.SaveChangesAsync(cancellationToken);
return Ok(new { Updated = updated, Total = rows.Count });
}
[HttpPost("sheets/{id:guid}/sync-participants")] [HttpPost("sheets/{id:guid}/sync-participants")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> SyncParticipants( public async Task<ActionResult> SyncParticipants(
@@ -834,6 +996,22 @@ public sealed class ExperimentGradesController(
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); .FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
} }
private async Task<ExperimentGradeSheet?> LoadEditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await ScopedSheets()
.Include(x => x.Items)
.Include(x => x.Records)
.ThenInclude(x => x.ItemScores)
.Include(x => x.Records)
.ThenInclude(x => x.Student)
.ThenInclude(x => x!.AdministrativeClass)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync( private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
ExperimentProject project, ExperimentProject project,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -974,6 +1152,93 @@ public sealed class ExperimentGradesController(
private static bool ValidScore(decimal? score) => private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100; !score.HasValue || score.Value is >= 0 and <= 100;
private static List<string> ExperimentImportHeaders(
IEnumerable<ExperimentGradeItem> items)
{
var headers = new List<string> { "学号", "姓名", "班级", "参与状态" };
headers.AddRange(items.OrderBy(item => item.SortOrder).Select(item => item.Name));
headers.AddRange(["安全违规", "实验次数", "实验总评(自动计算)", "教师评语"]);
return headers;
}
private static string ParticipationLabel(
ExperimentParticipationStatus status) => status switch
{
ExperimentParticipationStatus.Pending => "待登记",
ExperimentParticipationStatus.Completed => "已完成",
ExperimentParticipationStatus.Absent => "缺席",
ExperimentParticipationStatus.Excused => "请假",
ExperimentParticipationStatus.Makeup => "补做",
ExperimentParticipationStatus.Exempt => "免做",
_ => "待登记"
};
private static bool TryParseParticipationStatus(
ExcelRow row,
out ExperimentParticipationStatus status,
out string error)
{
error = string.Empty;
switch (row["参与状态"])
{
case "待登记": status = ExperimentParticipationStatus.Pending; return true;
case "已完成": status = ExperimentParticipationStatus.Completed; return true;
case "缺席": status = ExperimentParticipationStatus.Absent; return true;
case "请假": status = ExperimentParticipationStatus.Excused; return true;
case "补做": status = ExperimentParticipationStatus.Makeup; return true;
case "免做": status = ExperimentParticipationStatus.Exempt; return true;
default:
status = default;
error = "“参与状态”请填写待登记、已完成、缺席、请假、补做或免做。";
return false;
}
}
private static bool TryParseYesNo(string value, out bool result)
{
if (value == "是") { result = true; return true; }
if (value == "否") { result = false; return true; }
result = false;
return false;
}
private static decimal? ParseOptionalDecimal(
ExcelRow row,
string header,
decimal minimum,
decimal maximum,
List<string> errors)
{
var value = row[header];
if (string.IsNullOrWhiteSpace(value)) return null;
if (decimal.TryParse(value, NumberStyles.Number,
CultureInfo.InvariantCulture, out var result) &&
result >= minimum && result <= maximum)
return result;
errors.Add($"第 {row.RowNumber} 行:“{header}”请填写 {minimum:0}—{maximum:0} 的数值或留空。");
return null;
}
private ActionResult ImportValidationProblem(IReadOnlyList<string> errors)
{
foreach (var error in errors.Take(50)) ModelState.AddModelError("file", error);
if (errors.Count > 50)
ModelState.AddModelError("file", $"另有 {errors.Count - 50} 条错误未显示。");
return ValidationProblem(ModelState);
}
private static string ColumnLetter(int column)
{
var result = string.Empty;
while (column > 0)
{
column--;
result = (char)('A' + column % 26) + result;
column /= 26;
}
return result;
}
private static string? Normalize(string? value) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -4,6 +4,8 @@ using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades; using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Persistence;
using ClosedXML.Excel;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -72,6 +74,56 @@ public sealed class ExperimentGradesControllerTests
CancellationToken.None)); CancellationToken.None));
} }
[Fact]
public async Task Teacher_CanDownloadAndImportExperimentGradeTemplate()
{
await using var fixture = await ExperimentGradeFixture.CreateAsync();
var project = new ExperimentProject
{
TeachingTaskId = fixture.Task.Id,
Code = "LAB-EXCEL",
Name = "Excel 实验成绩",
ArrangementMode = ExperimentArrangementMode.Centralized,
StartDate = fixture.Term.StartDate,
EndDate = fixture.Term.EndDate,
Status = ExperimentProjectStatus.Published,
PublishedAt = DateTime.UtcNow
};
fixture.Db.ExperimentProjects.Add(project);
await fixture.Db.SaveChangesAsync();
var admin = fixture.ExperimentGrades(fixture.AdminScope);
await admin.CreateSheet(new ExperimentGradeSheetRequest(
project.Id, 1, 60,
[new ExperimentGradeItemRequest("操作", ExperimentGradeItemKind.Operation, 100)]),
CancellationToken.None);
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
fixture.Db.ChangeTracker.Clear();
var teacher = fixture.ExperimentGrades(fixture.TeacherScope);
var template = Assert.IsType<FileContentResult>(await teacher.DownloadTemplate(
sheet.Id, CancellationToken.None));
Assert.Equal("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
template.ContentType);
await using var templateStream = new MemoryStream(template.FileContents);
await using var stream = new MemoryStream();
using (var workbook = new XLWorkbook(templateStream))
{
var worksheet = workbook.Worksheet("实验成绩导入");
worksheet.Cell("D2").Value = "已完成";
worksheet.Cell("E2").Value = 85;
workbook.SaveAs(stream);
}
stream.Position = 0;
var file = new FormFile(stream, 0, stream.Length, "file", "实验成绩导入模板.xlsx");
Assert.IsType<OkObjectResult>(await teacher.ImportRecords(
sheet.Id, file, CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
Assert.Equal(85m, await fixture.Db.ExperimentGradeRecords
.Select(record => record.TotalScore)
.SingleAsync());
}
[Fact] [Fact]
public async Task ManagementList_IsPagedFilteredAndCollegeScoped() public async Task ManagementList_IsPagedFilteredAndCollegeScoped()
{ {
+40
View File
@@ -2,14 +2,17 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
Check, Check,
Download,
EditPen, EditPen,
Plus, Plus,
Promotion, Promotion,
Refresh, Refresh,
Setting, Setting,
Upload,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { import {
academicTermLabel, academicTermLabel,
@@ -44,6 +47,7 @@ const recordKeyword = ref('')
const schemeDialog = ref(false) const schemeDialog = ref(false)
const editingScheme = ref(false) const editingScheme = ref(false)
const schemeProject = ref<any | null>(null) const schemeProject = ref<any | null>(null)
const importFileInput = ref<HTMLInputElement>()
const schemeForm = reactive({ const schemeForm = reactive({
contributionWeight: 1, contributionWeight: 1,
@@ -290,6 +294,38 @@ async function saveRecords() {
} }
} }
function downloadTemplate() {
if (!detail.value) return
downloadApiFile(
`/experiment-grades/sheets/${detail.value.id}/template`,
'实验成绩导入模板.xlsx',
)
}
function chooseImportFile() {
importFileInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !detail.value) return
saving.value = true
try {
const result = await importExcel(
`/experiment-grades/sheets/${detail.value.id}/import`,
file,
)
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条实验成绩记录`)
await loadDetail()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
input.value = ''
saving.value = false
}
}
async function syncParticipants() { async function syncParticipants() {
if (!detail.value) return if (!detail.value) return
try { try {
@@ -777,12 +813,16 @@ onMounted(async () => {
@size-change="recordPage = 1; loadDetail()" @size-change="recordPage = 1; loadDetail()"
/> />
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
<footer class="workbench-actions"> <footer class="workbench-actions">
<div> <div>
<b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b> <b v-if="detail.reviewComment">审核意见:{{ detail.reviewComment }}</b>
<span>开课学院:{{ detail.courseCollegeName }}</span> <span>开课学院:{{ detail.courseCollegeName }}</span>
</div> </div>
<div> <div>
<el-button v-if="detail.canEdit" :icon="Download" @click="downloadTemplate">下载模板</el-button>
<el-button v-if="detail.canEdit" :icon="Upload" :loading="saving" @click="chooseImportFile">Excel 导入</el-button>
<el-button v-if="detail.canEdit" :icon="EditPen" :loading="saving" @click="saveRecords">保存本页</el-button> <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.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="danger" plain @click="returnSheet">退回修改</el-button>