修复其他考试提交

This commit is contained in:
2026-08-08 21:27:52 +08:00 Unverified
parent 87e0b208ef
commit 5f30dd6766
7 changed files with 182 additions and 48 deletions
+63 -5
View File
@@ -699,7 +699,7 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]); headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
var rows = sheet.Records.Select(record => var rows = sheet.Records.Select(record =>
{ {
@@ -716,6 +716,7 @@ public sealed class GradesController(
.FirstOrDefault(s => s.GradeItemId == item.Id)?.Score; .FirstOrDefault(s => s.GradeItemId == item.Id)?.Score;
values.Add(score); values.Add(score);
} }
values.Add(null);
values.Add(record.FinalScore); values.Add(record.FinalScore);
values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" : values.Add(record.ExamStatus == GradeExamStatus.Normal ? "正常" :
record.ExamStatus == GradeExamStatus.Absent ? "缺考" : record.ExamStatus == GradeExamStatus.Absent ? "缺考" :
@@ -729,13 +730,56 @@ public sealed class GradesController(
{ {
"请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。", "请勿修改第一行列名;学号、姓名、班级列请勿修改,用于匹配学生。",
"成绩列填写 0—100 的数值,留空表示暂未录入。", "成绩列填写 0—100 的数值,留空表示暂未录入。",
"总分(自动计算)列由 Excel 按各部分比例自动计算,仅供填写时预览;上传时系统不会采用该列结果。",
"考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。", "考试状态填写:正常、缺考、缓考 或 免修,留空默认为正常。",
$"本成绩单共 {sheet.Items.Count} 个分项:{string.Join("", itemNames)}。", $"本成绩单共 {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( 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; var taskName = sheet.TeachingTask!.Name;
return File(bytes, ExcelWorkbookHelper.ContentType, return File(bytes, ExcelWorkbookHelper.ContentType,
$"成绩导入模板-{taskName}.xlsx"); $"成绩导入模板-{taskName}.xlsx");
@@ -768,13 +812,15 @@ public sealed class GradesController(
var itemNames = sheet.Items.Select(i => i.Name).ToList(); var itemNames = sheet.Items.Select(i => i.Name).ToList();
var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" }; var headers = new List<string> { "学号", "姓名", "班级", "平时成绩" };
headers.AddRange(itemNames); headers.AddRange(itemNames);
headers.AddRange(["期末成绩", "考试状态", "备注"]); headers.AddRange(["总分(自动计算)", "期末成绩", "考试状态", "备注"]);
IReadOnlyList<ExcelRow> rows; IReadOnlyList<ExcelRow> rows;
try try
{ {
rows = await ExcelWorkbookHelper.ReadAsync( rows = await ExcelWorkbookHelper.ReadAsync(
file, headers, cancellationToken); file,
headers.Where(x => x != "总分(自动计算)").ToArray(),
cancellationToken);
} }
catch (InvalidDataException exception) catch (InvalidDataException exception)
{ {
@@ -810,7 +856,7 @@ public sealed class GradesController(
var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors); var regularScore = ParseOptionalDecimal(row, "平时成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue; 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); var finalScore = ParseOptionalDecimal(row, "期末成绩", 0, 100, errors);
if (errors.Count > 0 && errors[^1].Contains(row.RowNumber.ToString())) continue; 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) => private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); 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( public sealed record GradeSheetRequest(
@@ -146,7 +146,10 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
{ {
var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList(); var numbers = inputs.Select(x => x.StudentNumber.Trim()).ToList();
if (numbers.Count != numbers.Distinct(StringComparer.OrdinalIgnoreCase).Count()) return ValidationProblem("同一考试批次中学生不能重复出现。"); 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("存在不存在的学号,请先检查学生档案。"); if (students.Count != numbers.Count) return ValidationProblem("存在不存在的学号,请先检查学生档案。");
foreach (var item in inputs) foreach (var item in inputs)
{ {
@@ -157,11 +160,30 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
var beforeCount = await db.OtherExamResults.AsNoTracking() 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))) .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); .GroupBy(x => x.StudentId).Select(x => new { StudentId = x.Key, Count = x.Count() }).ToDictionaryAsync(x => x.StudentId, x => x.Count, ct);
return await db.ExecuteInRetriableTransactionAsync<ActionResult?>(async transaction =>
{
db.OtherExamResults.RemoveRange(batch.Results); db.OtherExamResults.RemoveRange(batch.Results);
batch.Status = OtherExamBatchStatus.Draft; 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); 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; return null;
}, ct);
} }
private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch private static string[] HeadersFor(OtherExamBatch batch) => batch.MetricKind switch
@@ -11,7 +11,8 @@ public static class ExcelWorkbookHelper
string sheetName, string sheetName,
IReadOnlyList<string> headers, IReadOnlyList<string> headers,
IEnumerable<IReadOnlyList<object?>> rows, IEnumerable<IReadOnlyList<object?>> rows,
IReadOnlyList<string>? instructions = null) IReadOnlyList<string>? instructions = null,
Action<IXLWorksheet, int>? configureRow = null)
{ {
using var workbook = new XLWorkbook(); using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add(sheetName); var sheet = workbook.Worksheets.Add(sheetName);
@@ -33,6 +34,7 @@ public static class ExcelWorkbookHelper
{ {
SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]); SetCellValue(sheet.Cell(rowNumber, column + 1), row[column]);
} }
configureRow?.Invoke(sheet, rowNumber);
rowNumber++; rowNumber++;
} }
+2 -2
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>2.3.0-rc3</Version> <Version>2.3.1-beta.1</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot> <SpaRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../web'))</SpaRoot>
@@ -32,7 +32,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" /> <PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "2.3.0-rc4", "version": "2.3.1-beta.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+42 -1
View File
@@ -384,6 +384,10 @@ function scoreClass(score: number | null) {
return '' return ''
} }
function isFailingScore(score: number | string | null | undefined) {
return score != null && score !== '' && Number(score) < 60
}
function itemScore(record: any, gradeItemId: string) { function itemScore(record: any, gradeItemId: string) {
return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId) return record.itemScores?.find((itemScore: any) => itemScore.gradeItemId === gradeItemId)
} }
@@ -473,7 +477,7 @@ onMounted(async () => {
:style="{ left: `${record.totalScore}%` }" :style="{ left: `${record.totalScore}%` }"
>{{ record.totalScore }}</span> >{{ record.totalScore }}</span>
</div> </div>
<div class="transcript-result"> <div class="transcript-result" :class="{ 'is-failed': isFailingScore(record.totalScore) }">
<span>{{ examStatusLabels[record.examStatus] }}</span> <span>{{ examStatusLabels[record.examStatus] }}</span>
<b v-if="record.totalScore != null">{{ record.totalScore }}</b> <b v-if="record.totalScore != null">{{ record.totalScore }}</b>
<b v-else></b> <b v-else></b>
@@ -602,12 +606,14 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<el-table-column :label="`平时 ${detail.regularWeight}%`" width="110"> <el-table-column :label="`平时 ${detail.regularWeight}%`" width="110">
<template #default="{ row }"> <template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.regularScore) }">
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'" v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.regularScore" v-model="row.regularScore"
:min="0" :max="100" :controls="false" size="small" :min="0" :max="100" :controls="false" size="small"
/> />
<span v-else>{{ row.regularScore ?? '' }}</span> <span v-else>{{ row.regularScore ?? '' }}</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
@@ -617,6 +623,7 @@ onMounted(async () => {
:width="item.sourceType === 'ExperimentSummary' ? 150 : 110" :width="item.sourceType === 'ExperimentSummary' ? 150 : 110"
> >
<template #default="{ row }"> <template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(itemScore(row, item.id)?.score) }">
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal' && item.sourceType !== 'ExperimentSummary'" v-if="detail.canEdit && row.examStatus === 'Normal' && item.sourceType !== 'ExperimentSummary'"
:model-value="itemScore(row, item.id)?.score" :model-value="itemScore(row, item.id)?.score"
@@ -627,26 +634,34 @@ onMounted(async () => {
{{ itemScore(row, item.id)?.score ?? '—' }} {{ itemScore(row, item.id)?.score ?? '—' }}
<small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small> <small v-if="item.sourceType === 'ExperimentSummary'">已锁定</small>
</span> </span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="`期末 ${detail.finalWeight}%`" width="110"> <el-table-column :label="`期末 ${detail.finalWeight}%`" width="110">
<template #default="{ row }"> <template #default="{ row }">
<div class="score-cell" :class="{ 'is-failed': isFailingScore(row.finalScore) }">
<el-input-number <el-input-number
v-if="detail.canEdit && row.examStatus === 'Normal'" v-if="detail.canEdit && row.examStatus === 'Normal'"
v-model="row.finalScore" v-model="row.finalScore"
:min="0" :max="100" :controls="false" size="small" :min="0" :max="100" :controls="false" size="small"
/> />
<span v-else>{{ row.finalScore ?? '—' }}</span> <span v-else>{{ row.finalScore ?? '—' }}</span>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="总评" width="90"> <el-table-column label="总评" width="90">
<template #default="{ row }"> <template #default="{ row }">
<div
class="score-cell"
:class="{ 'is-failed': isFailingScore(detail.canEdit && row.examStatus === 'Normal' ? calcPreviewTotal(row) : row.totalScore) }"
>
<template v-if="detail.canEdit && row.examStatus === 'Normal'"> <template v-if="detail.canEdit && row.examStatus === 'Normal'">
<b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))"> <b class="total-score preview" :class="scoreClass(calcPreviewTotal(row))">
{{ calcPreviewTotal(row) ?? '—' }} {{ calcPreviewTotal(row) ?? '—' }}
</b> </b>
</template> </template>
<b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b> <b v-else class="total-score" :class="scoreClass(row.totalScore)">{{ row.totalScore ?? '—' }}</b>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="考试状态" width="115"> <el-table-column label="考试状态" width="115">
@@ -795,6 +810,32 @@ onMounted(async () => {
.item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; } .item-row > span:first-child { flex: 1; font-size: 13px; font-weight: 650; }
.add-item-row { display: flex; align-items: center; gap: 8px; } .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 */ /* Preview total score */
.total-score.preview { .total-score.preview {
text-decoration: underline dashed; text-decoration: underline dashed;
+15 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue' import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http' import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
@@ -14,6 +14,7 @@ const selected = ref<any>(null)
const results = ref<ResultRow[]>([]) const results = ref<ResultRow[]>([])
const mine = reactive({ best: [] as any[], history: [] as any[] }) const mine = reactive({ best: [] as any[], history: [] as any[] })
const dialog = ref(false) const dialog = ref(false)
const publishDialog = ref(false)
const saving = ref(false) const saving = ref(false)
const form = reactive({ examCode: '', name: '', organizer: '', examDate: new Date().toISOString().slice(0, 10), metricKind: 3, maxScore: 100, levelOptions: '' }) const form = reactive({ examCode: '', name: '', organizer: '', examDate: new Date().toISOString().slice(0, 10), metricKind: 3, maxScore: 100, levelOptions: '' })
const selectedMetricKind = computed(() => Number(selected.value?.metricKind ?? 0)) const selectedMetricKind = computed(() => Number(selected.value?.metricKind ?? 0))
@@ -54,11 +55,19 @@ async function saveResults() {
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { saving.value = false } } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { saving.value = false }
} }
async function publish() { async function publish() {
publishDialog.value = true
}
async function confirmPublish() {
saving.value = true
try { try {
await ElMessageBox.confirm('确认发布本批次成绩?发布后学生可查看;修改后可再次发布。', '发布其他考试成绩')
await http.post(`/other-exams/batches/${selected.value.id}/publish`) await http.post(`/other-exams/batches/${selected.value.id}/publish`)
ElMessage.success('成绩已发布'); await openBatch(selected.value); await load() publishDialog.value = false
} catch (error: any) { if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error)) } ElMessage.success('成绩已发布')
await openBatch(selected.value)
await load()
} catch (error) {
ElMessage.error(`发布失败:${apiErrorMessage(error)}`)
} finally { saving.value = false }
} }
async function createBatch() { async function createBatch() {
try { await http.post('/other-exams/batches', form); dialog.value = false; ElMessage.success('考试批次已建立'); await load() } try { await http.post('/other-exams/batches', form); dialog.value = false; ElMessage.success('考试批次已建立'); await load() }
@@ -94,6 +103,7 @@ onMounted(load)
<section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section> <section v-if="selected" class="editor-panel"><div class="editor-heading"><div><span class="kicker">{{ selected.examCode }}</span><h2>{{ selected.name }}</h2><p>{{ selected.organizer || '未填写组织方' }} · {{ selected.examDate }} · {{ metricName(selected.metricKind) }}{{ selected.metricKind === 3 ? ` · 满分 ${selected.maxScore}` : '' }}</p></div><div class="editor-actions"><el-button :icon="Download" @click="downloadTemplate">下载导入模板</el-button><label class="upload-button"><Upload />批量导入<input type="file" accept=".xlsx,.xls" @change="importExcel" /></label><el-button :icon="Plus" @click="addResult">新增一行</el-button><el-button type="primary" :loading="saving" @click="saveResults">保存记录</el-button><el-button type="success" :icon="Promotion" @click="publish">发布</el-button></div></div><div class="editor-hint">学号输入完成后离开输入框,系统自动检索姓名、学院和班级;参加次数不需要填写,由系统按考试编码和考试日期自动计算。</div><el-table :data="results" class="score-table"><el-table-column label="学号" min-width="150" fixed><template #default="{ row }"><el-input v-model="row.studentNumber" placeholder="输入学号" @blur="lookupStudent(row)" /></template></el-table-column><el-table-column label="姓名" width="110"><template #default="{ row }"><span :class="{ 'unresolved': row.studentNumber && !row.studentName }">{{ row.studentName || '待检索' }}</span></template></el-table-column><el-table-column label="学院" min-width="150"><template #default="{ row }">{{ row.collegeName || '—' }}</template></el-table-column><el-table-column label="班级" min-width="150"><template #default="{ row }">{{ row.className || '—' }}</template></el-table-column><el-table-column v-if="selected.metricKind === 3" label="成绩" width="150"><template #default="{ row }"><el-input-number v-model="row.score" :min="0" :max="selected.maxScore" :precision="2" controls-position="right" placeholder="请输入分数" /></template></el-table-column><el-table-column v-if="selected.metricKind === 2" label="等级" width="150"><template #default="{ row }"><el-select v-model="row.level" placeholder="选择等级"><el-option v-for="level in (selected.levelOptions || '').split(',').filter(Boolean)" :key="level" :label="level" :value="level" /></el-select></template></el-table-column><el-table-column v-if="selected.metricKind === 1" label="考试结果" width="150"><template #default="{ row }"><el-select v-model="row.isPassed" placeholder="选择结果"><el-option label="合格" :value="true"/><el-option label="不合格" :value="false"/></el-select></template></el-table-column><el-table-column label="参加次数" width="100"><template #default="{ row }"><span class="auto-attempt">{{ row.attemptNumber || '自动' }}</span></template></el-table-column><el-table-column label="备注" min-width="180"><template #default="{ row }"><el-input v-model="row.notes" placeholder="可选" /></template></el-table-column></el-table><el-empty v-if="!results.length" description="还没有成绩记录,点击“新增一行”或使用批量导入" /></section>
</template> </template>
<el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog> <el-dialog v-model="dialog" title="新建其他考试场次" width="600px"><el-form label-width="100px"><el-form-item label="考试编码" required><el-input v-model="form.examCode" placeholder="如 CET4、IELTS、计算机二级;同一考试始终使用相同编码" /></el-form-item><el-form-item label="考试名称" required><el-input v-model="form.name" placeholder="如 大学英语四级" /></el-form-item><el-form-item label="组织方"><el-input v-model="form.organizer" /></el-form-item><el-form-item label="考试日期"><el-date-picker v-model="form.examDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="评价方式"><el-select v-model="form.metricKind"><el-option label="分数制" :value="3"/><el-option label="等级制" :value="2"/><el-option label="合格/不合格" :value="1"/></el-select></el-form-item><el-form-item v-if="form.metricKind === 3" label="满分"><el-input-number v-model="form.maxScore" :min="1" /></el-form-item><el-form-item v-if="form.metricKind === 2" label="等级顺序"><el-input v-model="form.levelOptions" placeholder="按最优到最差填写,如 A+,A,B,C,D" /></el-form-item></el-form><template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" @click="createBatch">建立场次</el-button></template></el-dialog>
<el-dialog v-model="publishDialog" title="发布其他考试成绩" width="460px" :close-on-click-modal="false"><div class="publish-confirm"><strong>确认发布“{{ selected?.name }}”成绩?</strong><p>发布后学生可以查看本批次成绩;如需更正,可以修改后再次发布。</p></div><template #footer><el-button @click="publishDialog = false">取消</el-button><el-button type="success" :loading="saving" @click="confirmPublish">确认发布</el-button></template></el-dialog>
</div> </div>
</template> </template>
@@ -105,4 +115,5 @@ onMounted(load)
.editor-heading > div:first-child { min-width: 0; } .editor-heading > div:first-child { min-width: 0; }
.editor-actions { flex: 0 0 auto; } .editor-actions { flex: 0 0 auto; }
.upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; } .upload-button { flex: 0 0 auto; min-width: 112px; white-space: nowrap; justify-content: center; line-height: 1.4; }
.publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }
</style> </style>