增强筛选

This commit is contained in:
2026-08-11 16:34:52 +08:00 Unverified
parent b08b4cc4df
commit eb20211f0e
5 changed files with 253 additions and 18 deletions
@@ -399,8 +399,22 @@ public sealed class CourseSelectionsController(
[HttpGet("offerings/{id:guid}/roster")] [HttpGet("offerings/{id:guid}/roster")]
[Authorize(Roles = RosterReaders)] [Authorize(Roles = RosterReaders)]
public async Task<ActionResult> GetRoster(Guid id, CancellationToken cancellationToken) public async Task<ActionResult> GetRoster(
Guid id,
string? keyword = null,
int? grade = null,
Guid? majorId = null,
Guid? administrativeClassId = null,
int studentPage = 1,
int studentPageSize = 20,
int waitlistPage = 1,
int waitlistPageSize = 20,
CancellationToken cancellationToken = default)
{ {
studentPage = Math.Max(1, studentPage);
studentPageSize = Math.Clamp(studentPageSize, 10, 100);
waitlistPage = Math.Max(1, waitlistPage);
waitlistPageSize = Math.Clamp(waitlistPageSize, 10, 100);
var offering = await db.CourseSelectionOfferings.AsNoTracking() var offering = await db.CourseSelectionOfferings.AsNoTracking()
.Where(x => x.Id == id) .Where(x => x.Id == id)
.Select(x => new .Select(x => new
@@ -428,11 +442,64 @@ public sealed class CourseSelectionsController(
if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId)) if (!isAssignedTeacher && !scope.CanAccessCollege(offering.CollegeId))
return Forbid(); return Forbid();
var students = await db.CourseEnrollments.AsNoTracking() var enrolledSource = db.CourseEnrollments.AsNoTracking()
.Where(x => .Where(x =>
x.CourseSelectionOfferingId == id && x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Enrolled) x.Status == CourseEnrollmentStatus.Enrolled);
var waitlistSource = db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Waitlisted);
var enrolledCount = await enrolledSource.CountAsync(cancellationToken);
var waitlistedCount = await waitlistSource.CountAsync(cancellationToken);
var rosterSource = db.CourseEnrollments.AsNoTracking().Where(x =>
x.CourseSelectionOfferingId == id &&
(x.Status == CourseEnrollmentStatus.Enrolled ||
x.Status == CourseEnrollmentStatus.Waitlisted));
var filterOptions = await rosterSource
.Select(x => new
{
Grade = x.Student!.AdministrativeClass!.Grade,
MajorId = x.Student.AdministrativeClass.MajorId,
MajorName = x.Student.AdministrativeClass.Major!.Name,
ClassId = x.Student.AdministrativeClassId,
ClassName = x.Student.AdministrativeClass.Name
})
.Distinct()
.ToListAsync(cancellationToken);
if (grade.HasValue)
{
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.Grade == grade.Value);
}
if (majorId.HasValue)
{
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClass!.MajorId == majorId.Value);
}
if (administrativeClassId.HasValue)
{
enrolledSource = enrolledSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
waitlistSource = waitlistSource.Where(x => x.Student!.AdministrativeClassId == administrativeClassId.Value);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
enrolledSource = enrolledSource.Where(x =>
x.Student!.StudentNumber.Contains(keyword) ||
x.Student.Name.Contains(keyword) ||
x.Student.AdministrativeClass!.Name.Contains(keyword));
waitlistSource = waitlistSource.Where(x =>
x.Student!.StudentNumber.Contains(keyword) ||
x.Student.Name.Contains(keyword) ||
x.Student.AdministrativeClass!.Name.Contains(keyword));
}
var enrolledTotal = await enrolledSource.CountAsync(cancellationToken);
var waitlistedTotal = await waitlistSource.CountAsync(cancellationToken);
var students = await enrolledSource
.OrderBy(x => x.Student!.StudentNumber) .OrderBy(x => x.Student!.StudentNumber)
.Skip((studentPage - 1) * studentPageSize)
.Take(studentPageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -445,12 +512,11 @@ public sealed class CourseSelectionsController(
x.EnrolledAt x.EnrolledAt
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var waitlistedRows = await db.CourseEnrollments.AsNoTracking() var waitlistedRows = await waitlistSource
.Where(x =>
x.CourseSelectionOfferingId == id &&
x.Status == CourseEnrollmentStatus.Waitlisted)
.OrderBy(x => x.WaitlistedAt) .OrderBy(x => x.WaitlistedAt)
.ThenBy(x => x.CreatedAt) .ThenBy(x => x.CreatedAt)
.Skip((waitlistPage - 1) * waitlistPageSize)
.Take(waitlistPageSize)
.Select(x => new .Select(x => new
{ {
x.Id, x.Id,
@@ -474,7 +540,7 @@ public sealed class CourseSelectionsController(
item.MajorName, item.MajorName,
item.Grade, item.Grade,
item.WaitlistedAt, item.WaitlistedAt,
Position = index + 1 Position = (waitlistPage - 1) * waitlistPageSize + index + 1
}) })
.ToList(); .ToList();
return Ok(new return Ok(new
@@ -487,10 +553,24 @@ public sealed class CourseSelectionsController(
offering.CourseName, offering.CourseName,
offering.CourseNature, offering.CourseNature,
offering.Capacity, offering.Capacity,
EnrolledCount = students.Count, EnrolledCount = enrolledCount,
Students = students, Students = students,
WaitlistedCount = waitlist.Count, StudentPage = studentPage,
StudentPageSize = studentPageSize,
StudentTotal = enrolledTotal,
WaitlistedCount = waitlistedCount,
Waitlist = waitlist, Waitlist = waitlist,
WaitlistPage = waitlistPage,
WaitlistPageSize = waitlistPageSize,
WaitlistTotal = waitlistedTotal,
FilterOptions = new
{
Grades = filterOptions.Select(x => x.Grade).Distinct().OrderBy(x => x),
Majors = filterOptions.Select(x => new { x.MajorId, x.MajorName }).Distinct()
.OrderBy(x => x.MajorName),
Classes = filterOptions.Select(x => new { x.ClassId, x.ClassName, x.MajorId })
.Distinct().OrderBy(x => x.ClassName)
},
CanManageWaitlist = CanManageWaitlist =
offering.RoundStatus == CourseSelectionRoundStatus.Open && offering.RoundStatus == CourseSelectionRoundStatus.Open &&
(scope.IsInRole(SystemRoles.SuperAdmin) || (scope.IsInRole(SystemRoles.SuperAdmin) ||
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization; using System.Globalization;
using Jiaowu.Api.Contracts;
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;
@@ -20,10 +21,38 @@ public sealed class OtherExamsController(AppDbContext db, ICurrentUserDataScope
[HttpGet("batches")] [HttpGet("batches")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
public async Task<ActionResult> GetBatches(CancellationToken ct) => Ok(await db.OtherExamBatches public async Task<ActionResult> GetBatches(
.AsNoTracking().OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt) string? keyword = null,
OtherExamBatchStatus? status = null,
OtherExamMetricKind? metricKind = null,
DateOnly? examDateFrom = null,
DateOnly? examDateTo = null,
int page = 1,
int pageSize = 20,
CancellationToken ct = default)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = db.OtherExamBatches.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
{
keyword = keyword.Trim();
source = source.Where(x =>
(x.ExamCode != null && x.ExamCode.Contains(keyword)) ||
x.Name.Contains(keyword) ||
(x.Organizer != null && x.Organizer.Contains(keyword)));
}
if (status.HasValue) source = source.Where(x => x.Status == status.Value);
if (metricKind.HasValue) source = source.Where(x => x.MetricKind == metricKind.Value);
if (examDateFrom.HasValue) source = source.Where(x => x.ExamDate >= examDateFrom.Value);
if (examDateTo.HasValue) source = source.Where(x => x.ExamDate <= examDateTo.Value);
var total = await source.CountAsync(ct);
var items = await source.OrderByDescending(x => x.ExamDate).ThenByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count }) .Select(x => new { x.Id, x.ExamCode, x.Name, x.Organizer, x.ExamDate, x.MetricKind, x.MaxScore, x.LevelOptions, x.Status, x.PublicationCount, x.PublishedAt, ResultCount = x.Results.Count })
.ToListAsync(ct)); .ToListAsync(ct);
return Ok(new PagedResult<object>(items, total, page, pageSize));
}
[HttpPost("batches")] [HttpPost("batches")]
[Authorize(Roles = Managers)] [Authorize(Roles = Managers)]
@@ -1,4 +1,5 @@
using Jiaowu.Api.Controllers; using Jiaowu.Api.Controllers;
using Jiaowu.Api.Contracts;
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;
@@ -11,6 +12,37 @@ namespace Jiaowu.Api.Tests;
public sealed class OtherExamsControllerTests public sealed class OtherExamsControllerTests
{ {
[Fact]
public async Task Batches_AreFilteredAndReturnedByPage()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>().UseSqlite(connection).Options);
await db.Database.EnsureCreatedAsync();
db.OtherExamBatches.AddRange(Enumerable.Range(1, 11).Select(number =>
new OtherExamBatch
{
ExamCode = $"CET-{number:00}",
Name = $"英语等级考试 {number}",
ExamDate = new DateOnly(2026, 6, number),
MetricKind = OtherExamMetricKind.Score,
MaxScore = 100,
Status = number == 11
? OtherExamBatchStatus.Published
: OtherExamBatchStatus.Draft
}));
await db.SaveChangesAsync();
var controller = new OtherExamsController(db, new TestDataScope(Guid.NewGuid()));
var result = Assert.IsType<OkObjectResult>(await controller.GetBatches(
keyword: "英语", status: OtherExamBatchStatus.Draft, page: 1,
pageSize: 10, ct: CancellationToken.None));
var page = Assert.IsType<PagedResult<object>>(result.Value);
Assert.Equal(10, page.Total);
Assert.Equal(10, page.Items.Count);
}
[Fact] [Fact]
public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction() public async Task ReplaceResults_ReplacesExistingResultsInsideTransaction()
{ {
+72 -1
View File
@@ -40,6 +40,13 @@ const editingRoundId = ref('')
const editingOfferingId = ref('') const editingOfferingId = ref('')
const roster = ref<any | null>(null) const roster = ref<any | null>(null)
const rosterLoading = ref(false) const rosterLoading = ref(false)
const rosterKeyword = ref('')
const rosterGrade = ref<number | undefined>()
const rosterMajorId = ref<string | undefined>()
const rosterClassId = ref<string | undefined>()
const rosterStudentPage = ref(1)
const rosterWaitlistPage = ref(1)
const rosterPageSize = 20
const eligibleStudents = ref<any[]>([]) const eligibleStudents = ref<any[]>([])
const eligibleTotal = ref(0) const eligibleTotal = ref(0)
const eligiblePage = ref(1) const eligiblePage = ref(1)
@@ -82,6 +89,8 @@ const selectedOfferings = computed(() =>
const previewOffering = computed(() => const previewOffering = computed(() =>
offerings.value.find((item) => item.id === previewOfferingId.value) ?? null, offerings.value.find((item) => item.id === previewOfferingId.value) ?? null,
) )
const rosterClasses = computed(() => (roster.value?.filterOptions?.classes ?? [])
.filter((item: any) => !rosterMajorId.value || item.majorId === rosterMajorId.value))
const selectableTasks = computed(() => { const selectableTasks = computed(() => {
const usedTaskIds = new Set( const usedTaskIds = new Set(
offerings.value offerings.value
@@ -540,6 +549,12 @@ async function deleteOffering(offering: any) {
} }
async function showRoster(offering: any) { async function showRoster(offering: any) {
rosterKeyword.value = ''
rosterGrade.value = undefined
rosterMajorId.value = undefined
rosterClassId.value = undefined
rosterStudentPage.value = 1
rosterWaitlistPage.value = 1
rosterDrawer.value = true rosterDrawer.value = true
await loadRoster(offering.id) await loadRoster(offering.id)
} }
@@ -548,7 +563,18 @@ async function loadRoster(offeringId: string) {
rosterLoading.value = true rosterLoading.value = true
try { try {
roster.value = ( roster.value = (
await http.get(`/course-selections/offerings/${offeringId}/roster`) await http.get(`/course-selections/offerings/${offeringId}/roster`, {
params: {
keyword: rosterKeyword.value.trim() || undefined,
grade: rosterGrade.value,
majorId: rosterMajorId.value,
administrativeClassId: rosterClassId.value,
studentPage: rosterStudentPage.value,
studentPageSize: rosterPageSize,
waitlistPage: rosterWaitlistPage.value,
waitlistPageSize: rosterPageSize,
},
})
).data ).data
} catch (error) { } catch (error) {
ElMessage.error(apiErrorMessage(error)) ElMessage.error(apiErrorMessage(error))
@@ -557,6 +583,12 @@ async function loadRoster(offeringId: string) {
} }
} }
function filterRoster() {
rosterStudentPage.value = 1
rosterWaitlistPage.value = 1
if (roster.value) void loadRoster(roster.value.id)
}
async function openProxyEnrollment() { async function openProxyEnrollment() {
studentKeyword.value = '' studentKeyword.value = ''
selectedStudentIds.value = [] selectedStudentIds.value = []
@@ -1346,6 +1378,25 @@ onMounted(async () => {
:closable="false" :closable="false"
title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。" title="强制选课将忽略容量、时间冲突、学分上限和重复课程等限制,直接加入名单。"
/> />
<div class="roster-filter">
<el-input
v-model="rosterKeyword"
clearable
placeholder="按学号、姓名或行政班筛选"
@keyup.enter="filterRoster"
@clear="filterRoster"
/>
<el-select v-model="rosterGrade" clearable placeholder="全部年级" @change="filterRoster">
<el-option v-for="grade in roster.filterOptions?.grades" :key="grade" :label="`${grade} 级`" :value="grade" />
</el-select>
<el-select v-model="rosterMajorId" clearable placeholder="全部专业" @change="() => { rosterClassId = undefined; filterRoster() }">
<el-option v-for="major in roster.filterOptions?.majors" :key="major.majorId" :label="major.majorName" :value="major.majorId" />
</el-select>
<el-select v-model="rosterClassId" clearable placeholder="全部行政班" @change="filterRoster">
<el-option v-for="item in rosterClasses" :key="item.classId" :label="item.className" :value="item.classId" />
</el-select>
<el-button :icon="Search" @click="filterRoster">查询</el-button>
</div>
<el-table v-loading="rosterLoading" :data="roster.students"> <el-table v-loading="rosterLoading" :data="roster.students">
<el-table-column prop="studentNumber" label="学号" width="130" /> <el-table-column prop="studentNumber" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="90" /> <el-table-column prop="name" label="姓名" width="90" />
@@ -1363,6 +1414,12 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无学生选课" /></template> <template #empty><el-empty description="暂无学生选课" /></template>
</el-table> </el-table>
<el-pagination
v-if="roster.studentTotal > rosterPageSize"
small background layout="total, prev, pager, next"
:current-page="rosterStudentPage" :page-size="rosterPageSize" :total="roster.studentTotal"
@current-change="(page: number) => { rosterStudentPage = page; loadRoster(roster.id) }"
/>
<section class="waitlist-panel"> <section class="waitlist-panel">
<div class="waitlist-panel-head"> <div class="waitlist-panel-head">
<div> <div>
@@ -1394,6 +1451,12 @@ onMounted(async () => {
</el-table-column> </el-table-column>
<template #empty><el-empty description="暂无候补学生" /></template> <template #empty><el-empty description="暂无候补学生" /></template>
</el-table> </el-table>
<el-pagination
v-if="roster.waitlistTotal > rosterPageSize"
small background layout="total, prev, pager, next"
:current-page="rosterWaitlistPage" :page-size="rosterPageSize" :total="roster.waitlistTotal"
@current-change="(page: number) => { rosterWaitlistPage = page; loadRoster(roster.id) }"
/>
</section> </section>
</template> </template>
</el-drawer> </el-drawer>
@@ -1528,6 +1591,11 @@ onMounted(async () => {
.offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); } .offering-ticket.waitlisted { border-color: #e6a23c; box-shadow: 0 10px 28px rgb(230 162 60 / 10%); }
.seat-meter > small { display: block; margin-top: 5px; color: #b7791f; } .seat-meter > small { display: block; margin-top: 5px; color: #b7791f; }
.waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); } .waitlist-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--line); }
.roster-filter { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
.roster-filter .el-input { max-width: 260px; }
.roster-filter .el-select { width: 140px; }
.roster-summary ~ :deep(.el-pagination),
.waitlist-panel :deep(.el-pagination) { justify-content: flex-end; margin-top: 10px; }
.waitlist-panel-head { .waitlist-panel-head {
display: flex; display: flex;
align-items: end; align-items: end;
@@ -1541,6 +1609,9 @@ onMounted(async () => {
.waitlist-panel-head small { color: var(--muted); text-align: right; } .waitlist-panel-head small { color: var(--muted); text-align: right; }
@media (max-width: 640px) { @media (max-width: 640px) {
.roster-filter { align-items: stretch; flex-direction: column; }
.roster-filter .el-input,
.roster-filter .el-select { width: 100%; max-width: none; }
.waitlist-panel-head { align-items: start; flex-direction: column; } .waitlist-panel-head { align-items: start; flex-direction: column; }
.waitlist-panel-head small { text-align: left; } .waitlist-panel-head small { text-align: left; }
} }
+27 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Download, Plus, Promotion, Upload } from '@element-plus/icons-vue' import { Download, Plus, Promotion, Refresh, Search, 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'
@@ -10,6 +10,13 @@ const auth = useAuthStore()
const isStudent = computed(() => auth.user?.roles.includes('Student')) const isStudent = computed(() => auth.user?.roles.includes('Student'))
const loading = ref(false) const loading = ref(false)
const batches = ref<any[]>([]) const batches = ref<any[]>([])
const batchKeyword = ref('')
const batchStatus = ref<number | undefined>()
const batchMetricKind = ref<number | undefined>()
const batchDateRange = ref<string[]>([])
const batchPage = ref(1)
const batchPageSize = 20
const batchTotal = ref(0)
const selected = ref<any>(null) 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[] })
@@ -22,11 +29,26 @@ const isScoreMetric = computed(() => selectedMetricKind.value === 3)
const isLevelMetric = computed(() => selectedMetricKind.value === 2) const isLevelMetric = computed(() => selectedMetricKind.value === 2)
const isPassMetric = computed(() => selectedMetricKind.value === 1) const isPassMetric = computed(() => selectedMetricKind.value === 1)
async function load() { async function load(resetPage = false) {
loading.value = true loading.value = true
try { try {
if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data) if (isStudent.value) Object.assign(mine, (await http.get('/other-exams/mine')).data)
else batches.value = (await http.get('/other-exams/batches')).data else {
if (resetPage) batchPage.value = 1
const response = (await http.get('/other-exams/batches', {
params: {
keyword: batchKeyword.value.trim() || undefined,
status: batchStatus.value,
metricKind: batchMetricKind.value,
examDateFrom: batchDateRange.value[0] || undefined,
examDateTo: batchDateRange.value[1] || undefined,
page: batchPage.value,
pageSize: batchPageSize,
},
})).data
batches.value = response.items
batchTotal.value = response.total
}
} catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false } } catch (error) { ElMessage.error(apiErrorMessage(error)) } finally { loading.value = false }
} }
async function openBatch(row: any) { async function openBatch(row: any) {
@@ -99,7 +121,7 @@ onMounted(load)
<section class="result-board history-board"><div class="board-title"><div><span class="kicker">历史记录</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section> <section class="result-board history-board"><div class="board-title"><div><span class="kicker">历史记录</span><h2>历史成绩</h2></div><span class="board-note">每次发布的结果都会保留</span></div><el-table :data="mine.history" v-loading="loading"><el-table-column prop="examName" label="考试" min-width="180"/><el-table-column prop="examCode" label="考试编码" width="130"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column prop="attemptNumber" label="第几次参加" width="110"/><el-table-column label="结果" min-width="140"><template #default="{ row }">{{ displayResult(row) }}</template></el-table-column><el-table-column prop="publishedAt" label="发布时间" width="170"/></el-table></section>
</template> </template>
<template v-else> <template v-else>
<section class="batch-panel"><div class="panel-heading"><div><span class="kicker">考试场次</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} ` : '草稿' }}</el-tag></template></el-table-column></el-table></section> <section class="batch-panel"><div class="panel-heading"><div><span class="kicker">考试场次</span><h2>考试场次</h2></div><span>点击场次进入成绩录入</span></div><div class="batch-filters"><el-input v-model="batchKeyword" clearable placeholder="考试编码、名称或组织方" @keyup.enter="load(true)" @clear="load(true)"/><el-select v-model="batchStatus" clearable placeholder="全部状态" @change="load(true)"><el-option label="草稿" :value="1"/><el-option label="已发布" :value="2"/></el-select><el-select v-model="batchMetricKind" clearable placeholder="全部方式" @change="load(true)"><el-option label="合格制" :value="1"/><el-option label="等级制" :value="2"/><el-option label="分数制" :value="3"/></el-select><el-date-picker v-model="batchDateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" @change="load(true)"/><el-button :icon="Search" @click="load(true)">查询</el-button><el-button :icon="Refresh" @click="() => load()">刷新</el-button></div><el-table :data="batches" v-loading="loading" @row-click="openBatch"><el-table-column prop="examCode" label="考试编码" width="140"/><el-table-column prop="name" label="考试名称" min-width="190"/><el-table-column prop="examDate" label="考试日期" width="120"/><el-table-column label="评价方式" width="100"><template #default="{ row }">{{ metricName(row.metricKind) }}</template></el-table-column><el-table-column prop="resultCount" label="已录入" width="90"/><el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 2 ? 'success' : 'info'">{{ row.status === 2 ? `已发布 ${row.publicationCount} 次` : '草稿' }}</el-tag></template></el-table-column></el-table><el-pagination v-if="batchTotal > batchPageSize" small background layout="total, prev, pager, next" :current-page="batchPage" :page-size="batchPageSize" :total="batchTotal" @current-change="(page: number) => { batchPage = page; load() }"/></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> <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>
@@ -113,6 +135,7 @@ onMounted(load)
.kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; } .kicker { color:var(--teal); font-size:11px; letter-spacing:.17em; font-weight:800; }.exam-hero h1,.board-title h2,.panel-heading h2,.editor-heading h2 { color:var(--ink); margin:7px 0; letter-spacing:-.025em; }.exam-hero h1 { font-size:32px; }.exam-hero p,.editor-heading p { color:var(--muted); margin:0; line-height:1.7; }.result-board,.batch-panel,.editor-panel { background:#fff; border:1px solid var(--line); border-radius:14px; box-shadow:0 12px 30px rgba(32,61,73,.06); margin-bottom:18px; overflow:hidden; }.board-title,.panel-heading { display:flex; justify-content:space-between; align-items:center; padding:20px 22px 14px; }.board-title h2,.panel-heading h2,.editor-heading h2 { font-size:20px; }.board-note,.panel-heading>span { color:var(--muted); font-size:13px; }.history-board { opacity:.96; }.best-value { color:var(--navy); font-variant-numeric:tabular-nums; }.editor-heading { display:flex; justify-content:space-between; gap:20px; align-items:center; padding:20px 22px 14px; }.editor-actions { display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }.upload-button { display:inline-flex; align-items:center; gap:5px; border:1px solid #dcdfe6; border-radius:4px; padding:8px 14px; color:#606266; cursor:pointer; font-size:14px; }.upload-button:hover { color:var(--navy); border-color:var(--navy); }.upload-button input { display:none; }.editor-hint { margin:0 22px 14px; padding:11px 14px; border-left:3px solid #d4a72c; background:#fff9e8; color:#786223; font-size:13px; }.auto-attempt { display:inline-flex; align-items:center; padding:4px 8px; border-radius:20px; background:#edf6f5; color:var(--teal); font-size:12px; }.unresolved { color:#c27b18; }.score-table :deep(.el-input-number) { width:125px; }.score-table :deep(.el-table__cell) { padding:12px 0; }
@media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } } @media (max-width:760px) { .exam-hero,.editor-heading,.board-title,.panel-heading { display:block; }.exam-hero .el-button { margin-top:16px; }.editor-actions { justify-content:flex-start; margin-top:16px; }.board-note,.panel-heading>span { display:block; margin-top:5px; }.editor-hint { margin-left:14px; margin-right:14px; } }
.editor-heading > div:first-child { min-width: 0; } .editor-heading > div:first-child { min-width: 0; }
.batch-filters { display:flex; flex-wrap:wrap; gap:8px; padding:0 22px 16px; }.batch-filters .el-input { max-width:280px; }.batch-filters .el-select { width:120px; }.batch-panel :deep(.el-pagination) { justify-content:flex-end; padding:14px 22px; }
.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; } .publish-confirm { color:var(--ink); line-height:1.7; }.publish-confirm p { margin:8px 0 0; color:var(--muted); font-size:13px; }