---
成绩册 Excel 导入/导出 — 已完成
后端 (GradesController.cs)
GET /api/grades/sheets/{id}/template — 下载成绩导入模板
- 根据成绩单的动态分项(GradeItems)生成对应的 Excel 列:学号 | 姓名 | 班级 | 平时成绩 | [分项1] | [分项2] | ... | 期末成绩 | 考试状态 | 备注
- 预填已有学生信息和已有成绩
- 附带填写说明工作表
POST /api/grades/sheets/{id}/import — 从 Excel 导入成绩
- 按学号匹配学生,批量更新平时成绩、分项成绩、期末成绩、考试状态、备注
- 支持留空(不覆盖已有成绩)、0-100 范围校验、考试状态校验(正常/缺考/缓考/免修)
- 导入后自动重新计算总评成绩和绩点
- 仅限 Draft/Returned 状态的成绩单,仅限有编辑权限的用户
- 错误时整批回滚,最多显示 50 条错误详情
前端 (GradesView.vue)
在成绩登记册底部操作栏新增两个按钮(仅在 canEdit 时显示):
- 下载模板 — 调用 downloadApiFile 下载当前成绩单的 Excel 模板
- Excel导入 — 通过隐藏 file input 选择 .xlsx 文件,调用 importExcel 上传
工作流程
1. 教师打开成绩单 → 点击「下载模板」
2. 在 Excel 中填写/修改学生成绩
3. 点击「Excel导入」选择文件上传
4. 系统自动校验、合并成绩、重新计算总评 → 提示更新条数
5. 成绩表刷新显示最新数据
✻ Crunched for 5m 50s
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
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 Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -531,6 +533,196 @@ public sealed class GradesController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/template")]
|
||||||
|
[Authorize(Roles = SheetUsers)]
|
||||||
|
public async Task<IActionResult> DownloadTemplate(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await AccessibleSheets()
|
||||||
|
.Include(x => x.Items.OrderBy(i => i.SortOrder))
|
||||||
|
.Include(x => x.Records.OrderBy(r => r.Student!.StudentNumber))
|
||||||
|
.ThenInclude(x => x.Student)
|
||||||
|
.ThenInclude(x => x!.AdministrativeClass)
|
||||||
|
.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();
|
||||||
|
|
||||||
|
var itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
|
headers.AddRange(itemNames);
|
||||||
|
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||||||
|
|
||||||
|
var rows = sheet.Records.Select(record =>
|
||||||
|
{
|
||||||
|
var values = new List<object?>
|
||||||
|
{
|
||||||
|
record.Student!.StudentNumber,
|
||||||
|
record.Student.Name,
|
||||||
|
record.Student.AdministrativeClass!.Name,
|
||||||
|
record.RegularScore
|
||||||
|
};
|
||||||
|
foreach (var item in sheet.Items)
|
||||||
|
{
|
||||||
|
var score = record.ItemScores
|
||||||
|
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
|
||||||
|
values.Add(score);
|
||||||
|
}
|
||||||
|
values.Add(record.FinalScore);
|
||||||
|
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
|
||||||
|
record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
|
||||||
|
record.ExamStatus == GradeExamStatus.Deferred ? "缓考" :
|
||||||
|
record.ExamStatus == GradeExamStatus.Exempt ? "免修" : "正常");
|
||||||
|
values.Add(record.Notes);
|
||||||
|
return (IReadOnlyList<object?>)values;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var instructions = new List<string>
|
||||||
|
{
|
||||||
|
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
|
||||||
|
"成绩列填写 0—100 的数值,留空表示暂未录入。",
|
||||||
|
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
|
||||||
|
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("、", itemNames)}。",
|
||||||
|
"导入后会自动重新计算总评成绩和绩点。"
|
||||||
|
};
|
||||||
|
|
||||||
|
var bytes = ExcelWorkbookHelper.Create(
|
||||||
|
"成绩导入", headers, rows, instructions);
|
||||||
|
var taskName = sheet.TeachingTask!.Name;
|
||||||
|
return File(bytes, ExcelWorkbookHelper.ContentType,
|
||||||
|
$"成绩导入模板-{taskName}.xlsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/import")]
|
||||||
|
[Authorize(Roles = SheetUsers)]
|
||||||
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
|
public async Task<ActionResult> ImportGrades(
|
||||||
|
Guid id,
|
||||||
|
IFormFile file,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await AccessibleSheets()
|
||||||
|
.Include(x => x.Items.OrderBy(i => i.SortOrder))
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.Student)
|
||||||
|
.ThenInclude(x => x!.AdministrativeClass)
|
||||||
|
.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 itemNames = sheet.Items.Select(i => i.Name).ToList();
|
||||||
|
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
|
||||||
|
headers.AddRange(itemNames);
|
||||||
|
headers.AddRange(["期末成绩", "考试状态", "备注"]);
|
||||||
|
|
||||||
|
IReadOnlyList<ExcelRow> rows;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rows = await ExcelWorkbookHelper.ReadAsync(
|
||||||
|
file, headers, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException exception)
|
||||||
|
{
|
||||||
|
return ValidationProblem(exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.Count == 0)
|
||||||
|
return ValidationProblem("Excel 中没有可导入的成绩数据。");
|
||||||
|
|
||||||
|
var studentMap = sheet.Records.ToDictionary(
|
||||||
|
x => x.Student!.StudentNumber,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var itemMap = sheet.Items.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
var updated = 0;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var studentNumber = row["学号"]?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(studentNumber))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:学号不能为空。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!studentMap.TryGetValue(studentNumber, out var record))
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:学号“{studentNumber}”不在本成绩单中。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse regular score
|
||||||
|
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
|
||||||
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
|
// Parse final score
|
||||||
|
var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
|
||||||
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
|
// Parse item scores
|
||||||
|
var itemScores = new List<(Guid GradeItemId, decimal? Score)>();
|
||||||
|
foreach (var itemName in itemNames)
|
||||||
|
{
|
||||||
|
if (itemMap.TryGetValue(itemName, out var item))
|
||||||
|
{
|
||||||
|
var score = ParseOptionalDecimal(row, itemName, 0, 100, errors);
|
||||||
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString()))
|
||||||
|
break;
|
||||||
|
itemScores.Add((item.Id, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue;
|
||||||
|
|
||||||
|
// Parse exam status
|
||||||
|
var examStatusText = row["考试状态"]?.Trim();
|
||||||
|
var examStatus = GradeExamStatus.Normal;
|
||||||
|
if (!string.IsNullOrWhiteSpace(examStatusText))
|
||||||
|
{
|
||||||
|
if (examStatusText == "缺考") examStatus = GradeExamStatus.Absent;
|
||||||
|
else if (examStatusText == "缓考") examStatus = GradeExamStatus.Deferred;
|
||||||
|
else if (examStatusText == "免修") examStatus = GradeExamStatus.Exempt;
|
||||||
|
else if (examStatusText != "正常")
|
||||||
|
{
|
||||||
|
errors.Add($"第 {row.RowNumber} 行:考试状态“{examStatusText}”无效,请填写正常、缺考、缓考或免修。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
record.RegularScore = regularScore;
|
||||||
|
record.FinalScore = finalScore;
|
||||||
|
record.ExamStatus = examStatus;
|
||||||
|
record.Notes = Normalize(row["备注"]);
|
||||||
|
|
||||||
|
foreach (var (itemId, score) in itemScores)
|
||||||
|
{
|
||||||
|
var existingScore = record.ItemScores
|
||||||
|
.FirstOrDefault(s => s.GradeItemId == itemId);
|
||||||
|
if (existingScore != null)
|
||||||
|
existingScore.Score = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
return ImportValidationProblem(errors);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new { updated, total = rows.Count });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("student/transcript")]
|
[HttpGet("student/transcript")]
|
||||||
[Authorize(Roles = SystemRoles.Student)]
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
public async Task<ActionResult> GetStudentTranscript(
|
public async Task<ActionResult> GetStudentTranscript(
|
||||||
@@ -670,6 +862,38 @@ public sealed class GradesController(
|
|||||||
Status = StatusCodes.Status409Conflict
|
Status = StatusCodes.Status409Conflict
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 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 static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Delete,
|
Delete,
|
||||||
DocumentChecked,
|
DocumentChecked,
|
||||||
|
Download,
|
||||||
EditPen,
|
EditPen,
|
||||||
Plus,
|
Plus,
|
||||||
Promotion,
|
Promotion,
|
||||||
Refresh,
|
Refresh,
|
||||||
|
Upload,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
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'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -230,6 +233,37 @@ async function submitSheet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importFileInput = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
function downloadTemplate() {
|
||||||
|
downloadApiFile(
|
||||||
|
`/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) return
|
||||||
|
try {
|
||||||
|
const result = await importExcel(
|
||||||
|
`/grades/sheets/${detail.value.id}/import`,
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
ElMessage.success(`导入完成:已更新 ${result.data.updated} 条成绩记录`)
|
||||||
|
await selectTask(selectedTask.value)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(apiErrorMessage(error))
|
||||||
|
} finally {
|
||||||
|
input.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function approveSheet() {
|
async function approveSheet() {
|
||||||
try {
|
try {
|
||||||
await http.post(`/grades/sheets/${detail.value.id}/approve`)
|
await http.post(`/grades/sheets/${detail.value.id}/approve`)
|
||||||
@@ -531,6 +565,8 @@ onMounted(async () => {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input ref="importFileInput" type="file" accept=".xlsx" style="display:none" @change="handleImport" />
|
||||||
|
|
||||||
<footer class="grade-actions">
|
<footer class="grade-actions">
|
||||||
<div class="workflow-status">
|
<div class="workflow-status">
|
||||||
<span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span>
|
<span>{{ statusMeta[detail.status]?.icon }} {{ statusLabels[detail.status] }}</span>
|
||||||
@@ -540,6 +576,8 @@ onMounted(async () => {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="grade-actions-buttons">
|
<div class="grade-actions-buttons">
|
||||||
|
<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="EditPen" @click="saveRecords">保存成绩</el-button>
|
<el-button v-if="detail.canEdit" :icon="EditPen" @click="saveRecords">保存成绩</el-button>
|
||||||
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
|
<el-button v-if="detail.canEdit" type="primary" :icon="Promotion" @click="submitSheet">提交审核</el-button>
|
||||||
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
|
<el-button v-if="detail.canReview" type="danger" plain @click="openReturn">退回修改</el-button>
|
||||||
|
|||||||
Reference in New Issue
Block a user