diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs index 2308038..3fa7c35 100644 --- a/src/Jiaowu.Api/Controllers/GradesController.cs +++ b/src/Jiaowu.Api/Controllers/GradesController.cs @@ -699,7 +699,7 @@ public sealed class GradesController( var itemNames = sheet.Items.Select(i => i.Name).ToList(); var headers = new List { "学号", "姓名", "班级", "平时成绩" }; headers.AddRange(itemNames); - headers.AddRange(["期末成绩", "考试状态", "备注"]); + headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]); var rows = sheet.Records.Select(record => { @@ -716,6 +716,7 @@ public sealed class GradesController( .FirstOrDefault(s => s.GradeItemId == item.Id)?.Score; values.Add(score); } + values.Add(null); values.Add(record.FinalScore); values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" : record.ExamStatus == GradeExamStatus.Absent ? "缺考" : @@ -729,13 +730,56 @@ public sealed class GradesController( { "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。", "成绩列填写 0—100 的数值,留空表示暂未录入。", + "总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。", "考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。", $"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。", "导入后会自动重新计算总评成绩和绩点。" }; + var regularColumn = 4; + var itemColumns = Enumerable.Range(5, sheet.Items.Count).ToArray(); + var totalColumn = regularColumn + itemColumns.Length + 1; + var finalColumn = totalColumn + 1; + var statusColumn = finalColumn + 1; + var weightedColumns = new List<(int Column, decimal Weight)> { (regularColumn, sheet.RegularWeight) }; + weightedColumns.AddRange(sheet.Items.Select((item, index) => + (itemColumns[index], item.Weight))); + weightedColumns.Add((finalColumn, sheet.FinalWeight)); + var requiredColumns = weightedColumns.Where(x => x.Weight > 0).ToArray(); + var scoreColumns = weightedColumns.Select(x => x.Column) + .Append(totalColumn) + .ToArray(); + var bytes = ExcelWorkbookHelper.Create( - "成绩导入", headers, rows, instructions); + "成绩导入", headers, rows, instructions, + (worksheet, rowNumber) => + { + var componentReferences = requiredColumns + .Select(x => $"{ColumnLetter(x.Column)}{rowNumber}") + .ToArray(); + var weightedExpression = string.Join("+", weightedColumns.Select(x => + $"{ColumnLetter(x.Column)}{rowNumber}*{x.Weight.ToString(CultureInfo.InvariantCulture)}/100")); + var statusReference = $"{ColumnLetter(statusColumn)}{rowNumber}"; + var formula = + $"=IF(OR({statusReference}=\"缺考\",{statusReference}=\"缓考\",{statusReference}=\"免修\"),\"\",IF(COUNT({string.Join(",", componentReferences)})={requiredColumns.Length},ROUND({weightedExpression},1),\"\"))"; + var cell = worksheet.Cell(rowNumber, totalColumn); + cell.FormulaA1 = formula; + cell.Style.NumberFormat.Format = "0.0"; + cell.Style.Font.Bold = true; + cell.Style.Fill.BackgroundColor = ClosedXML.Excel.XLColor.FromHtml("#E8F1FB"); + + foreach (var scoreColumn in scoreColumns) + { + var conditionalFormat = worksheet + .Range(rowNumber, scoreColumn, rowNumber, scoreColumn) + .AddConditionalFormat(); + var failingScoreFormat = conditionalFormat.WhenLessThan(60); + failingScoreFormat.Fill.BackgroundColor = + ClosedXML.Excel.XLColor.FromHtml("#FDECEC"); + failingScoreFormat.Font.FontColor = + ClosedXML.Excel.XLColor.FromHtml("#B42318"); + } + }); var taskName = sheet.TeachingTask!.Name; return File(bytes, ExcelWorkbookHelper.ContentType, $"成绩导入模板-{taskName}.xlsx"); @@ -768,13 +812,15 @@ public sealed class GradesController( var itemNames = sheet.Items.Select(i => i.Name).ToList(); var headers = new List { "学号", "姓名", "班级", "平时成绩" }; headers.AddRange(itemNames); - headers.AddRange(["期末成绩", "考试状态", "备注"]); + headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]); IReadOnlyList rows; try { rows = await ExcelWorkbookHelper.ReadAsync( - file, headers, cancellationToken); + file, + headers.Where(x => x != "总分(自动计算)").ToArray(), + cancellationToken); } catch (InvalidDataException exception) { @@ -810,7 +856,7 @@ public sealed class GradesController( var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors); if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue; - // Parse final score + // Parse final score. The formula-driven total column is intentionally ignored. var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors); if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue; @@ -1041,6 +1087,18 @@ public sealed class GradesController( private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static string ColumnLetter(int column) + { + var result = string.Empty; + while (column > 0) + { + column--; + result = (char)('A' + column % 26) + result; + column /= 26; + } + return result; + } } public sealed record GradeSheetRequest( diff --git a/src/Jiaowu.Api/Controllers/OtherExamsController.cs b/src/Jiaowu.Api/Controllers/OtherExamsController.cs index 04e3884..1dfcc91 100644 --- a/src/Jiaowu.Api/Controllers/OtherExamsController.cs +++ b/src/Jiaowu.Api/Controllers/OtherExamsController.cs @@ -146,7 +146,10 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope { var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList(); if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。"); - var students = await db.Students.Where(x => numbers.Contains(x.StudentNumber)).ToDictionaryAsync(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase, ct); + var studentRows = await db.Students + .Where(x => numbers.Contains(x.StudentNumber)) + .ToListAsync(ct); + var students = studentRows.ToDictionary(x => x.StudentNumber, StringComparer.OrdinalIgnoreCase); if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。"); foreach (var item in inputs) { @@ -157,11 +160,30 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope var beforeCount = await db.OtherExamResults.AsNoTracking() .Where(x => x.OtherExamBatchId != batch.Id && studentIds.Contains(x.StudentId) && (x.OtherExamBatch!.ExamCode == batch.ExamCode || (x.OtherExamBatch.ExamCode == null && batch.ExamCode == null && x.OtherExamBatch.Name == batch.Name)) && (x.OtherExamBatch.ExamDate < batch.ExamDate || (x.OtherExamBatch.ExamDate == batch.ExamDate && x.OtherExamBatch.CreatedAt < batch.CreatedAt))) .GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct); - db.OtherExamResults.RemoveRange(batch.Results); - batch.Status = OtherExamBatchStatus.Draft; - batch.Results = inputs.Select(x => { var student = students[x.StudentNumber.Trim()]; return new OtherExamResult { OtherExamBatchId = batch.Id, StudentId = student.Id, AttemptNumber = (beforeCount.GetValueOrDefault(student.Id) + 1), Score = x.Score, Level = Normalize(x.Level), IsPassed = x.IsPassed, Notes = Normalize(x.Notes) }; }).ToList(); - await db.SaveChangesAsync(ct); - return null; + return await db.ExecuteInRetriableTransactionAsync(async transaction => + { + db.OtherExamResults.RemoveRange(batch.Results); + batch.Status = OtherExamBatchStatus.Draft; + await db.SaveChangesAsync(ct); + + batch.Results = inputs.Select(x => + { + var student = students[x.StudentNumber.Trim()]; + return new OtherExamResult + { + OtherExamBatchId = batch.Id, + StudentId = student.Id, + AttemptNumber = beforeCount.GetValueOrDefault(student.Id) + 1, + Score = x.Score, + Level = Normalize(x.Level), + IsPassed = x.IsPassed, + Notes = Normalize(x.Notes) + }; + }).ToList(); + await db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + return null; + }, ct); } private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch diff --git a/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs b/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs index 6a85626..1babadc 100644 --- a/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs +++ b/src/Jiaowu.Api/Infrastructure/Excel/ExcelWorkbookHelper.cs @@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper string sheetName, IReadOnlyList headers, IEnumerable> rows, - IReadOnlyList? instructions = null) + IReadOnlyList? instructions = null, + Action? configureRow = null) { using var workbook = new XLWorkbook(); var sheet = workbook.Worksheets.Add(sheetName); @@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper { SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]); } + configureRow?.Invoke(sheet, rowNumber); rowNumber++; } diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index f6805a3..e93401c 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 2.3.0-rc3 + 2.3.1-beta.1 enable enable $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web')) @@ -32,7 +32,7 @@ all - + diff --git a/web/package.json b/web/package.json index 71fb60e..f47c4bf 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "web", "private": true, - "version": "2.3.0-rc4", + "version": "2.3.1-beta.1", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/views/GradesView.vue b/web/src/views/GradesView.vue index 0be6c33..60dd8cc 100644 --- a/web/src/views/GradesView.vue +++ b/web/src/views/GradesView.vue @@ -384,6 +384,10 @@ function scoreClass(score: number | null) { return '' } +function isFailingScore(score: number | string | null | undefined) { + return score != null && score !== '' && Number(score) < 60 +} + function itemScore(record: any, gradeItemId: string) { return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId) } @@ -473,7 +477,7 @@ onMounted(async () => { :style="{ left: `${record.totalScore}%` }" >{{ record.totalScore }} -
+
{{ examStatusLabels[record.examStatus] }} {{ record.totalScore }} @@ -602,12 +606,14 @@ onMounted(async () => { { :width="item.sourceType === 'ExperimentSummary' ? 150 : 110" > @@ -795,6 +810,32 @@ onMounted(async () => { .item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; } .add-item-row { display: flex; align-items: center; gap: 8px; } +.score-cell { + min-height: 30px; + margin: -5px; + padding: 5px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 4px; +} +.score-cell.is-failed { + color: #b42318; + background: #fdecec; + border-color: #f4b8b2; + font-weight: 700; +} +.score-cell.is-failed :deep(input) { color: #b42318; font-weight: 700; } +.transcript-result.is-failed { + color: #b42318; + background: #fdecec; + border: 1px solid #f4b8b2; + border-radius: 4px; + padding: 8px; +} +.transcript-result.is-failed b { color: #b42318; } + /* Preview total score */ .total-score.preview { text-decoration: underline dashed; diff --git a/web/src/views/OtherExamsView.vue b/web/src/views/OtherExamsView.vue index 2e41e11..5e85147 100644 --- a/web/src/views/OtherExamsView.vue +++ b/web/src/views/OtherExamsView.vue @@ -1,6 +1,6 @@