Files
Academic-Affairs-System/web/src/views/MakeupExamsView.vue
T
biss c91d826169 学年学期页面新增“设为当前”“归档”“撤销归档”,当前学期不能归档、删除或直接取消;已归档学期需先撤销才能重新设为当前。[BaseDataController.cs (line 286)](/E:/jiaowu/src/Jiaowu.Api/Controllers/BaseDataController.cs:286)
切换后,其他学期自动变为历史态;已归档学期进一步弱化。所有主要学期选择器统一默认当前,并显示“当前 / 历史 / 已归档”。[academicTerms.ts (line 1)](/E:/jiaowu/web/src/utils/academicTerms.ts:1)
桌面使用分层表格,390px 手机端改为独立学期卡片,操作不会再挤压表格。[BaseDataView.vue (line 211)](/E:/jiaowu/web/src/views/BaseDataView.vue:211)
归档定义为“历史显示状态”,不是成绩冻结:补考成绩仍可录入并回写,已发布成绩仍可走成绩更正审批。
已加入 SQLite 开发迁移、MySQL 正式迁移和切换/归档/撤销测试。[20260726143000_AcademicTermArchiving.cs (line 1)](/E:/jiaowu/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726143000_AcademicTermArchiving.cs:1)
2026-07-26 11:14:05 +08:00

645 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search, MagicStick } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some((r) => ['SuperAdmin', 'AcademicAdmin'].includes(r)) ?? false)
const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isManager.value)
const plans = ref<any[]>([])
const selected = ref<any | null>(null)
const personal = ref<any[]>([])
const teachingSessions = ref<any[]>([])
const scoreDialogVisible = ref(false)
const scoreSession = ref<any | null>(null)
const scoreMap = reactive<Record<string, number | null>>({})
const scoreSaving = ref(false)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const rooms = ref<any[]>([])
const teachers = ref<any[]>([])
const buildings = ref<any[]>([])
const timeSlots = ref<any[]>([])
const loading = ref(false)
const arrangeLoading = ref(false)
const planDialog = ref(false)
const sessionDialog = ref(false)
const editingSession = ref<any | null>(null)
const rosterDrawer = ref(false)
const roster = ref<any | null>(null)
const enrollmentDialog = ref(false)
const enrollmentSession = ref<any | null>(null)
const eligibleStudents = ref<any[]>([])
const eligibleLoading = ref(false)
const selectedStudentIds = ref<string[]>([])
const planForm = reactive<Record<string, any>>({})
const sessionForm = reactive<Record<string, any>>({})
const statusLabels: Record<string, string> = {
Draft: '草稿', Published: '已发布', Archived: '已归档',
}
function timeText(startsAt: string) {
return new Date(startsAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
function dateOnlyText(value: string) {
if (!value) return ''
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' }).format(new Date(value))
}
function periodLabel(session: any) {
const end = session.startPeriod + session.periodCount - 1
const startSlot = timeSlots.value.find((s: any) => s.periodNumber === session.startPeriod)
const endSlot = timeSlots.value.find((s: any) => s.periodNumber === end)
const timeRange = startSlot && endSlot ? `${startSlot.startsAt}${endSlot.endsAt}` : ''
return `第 ${session.startPeriod}-${end}${timeRange ? ' · ' + timeRange : ''}`
}
async function load() {
loading.value = true
try {
if (!isManager.value) {
personal.value = (await http.get('/makeup-exams/my-schedule')).data
if (isTeacher.value) {
teachingSessions.value = (await http.get('/makeup-exams/my-teaching-sessions')).data
}
return
}
plans.value = (await http.get('/makeup-exams/plans')).data
const plan = plans.value.find((x) => x.id === selected.value?.id)
?? plans.value.find((x) => x.termIsCurrent)
?? plans.value[0]
if (plan) await selectPlan(plan.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { loading.value = false }
}
async function selectPlan(id: string) {
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
}
function openPlan() {
Object.assign(planForm, {
academicTermId: defaultAcademicTermId(terms.value),
name: '', notes: '',
})
planDialog.value = true
}
async function savePlan() {
try {
await http.post('/makeup-exams/plans', planForm)
planDialog.value = false
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function openSession(existing?: any) {
editingSession.value = existing ?? null
const firstSlot = timeSlots.value[0]
Object.assign(sessionForm, {
teachingTaskId: existing?.teachingTaskId ?? undefined,
classroomId: existing?.classroomId ?? undefined,
examDate: existing?.examDate ?? '',
startPeriod: existing?.startPeriod ?? (firstSlot?.periodNumber ?? 1),
periodCount: existing?.periodCount ?? 2,
requiredBuildingId: existing?.requiredBuildingId ?? undefined,
requiredInvigilatorCount: existing?.requiredInvigilatorCount ?? 2,
invigilatorIds: existing?.invigilatorIds ?? [],
notes: existing?.notes ?? '',
})
sessionDialog.value = true
}
async function saveSession() {
try {
const payload = { ...sessionForm }
if (editingSession.value) {
await http.put(`/makeup-exams/plans/${selected.value.id}/sessions/${editingSession.value.id}`, payload)
} else {
await http.post(`/makeup-exams/plans/${selected.value.id}/sessions`, payload)
}
sessionDialog.value = false
editingSession.value = null
await selectPlan(selected.value.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function removeSession(row: any) {
try {
await ElMessageBox.confirm(`移除"${row.courseName}"补考场次?`, '移除补考场次', { type: 'warning' })
await http.delete(`/makeup-exams/plans/${selected.value.id}/sessions/${row.id}`)
await selectPlan(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function autoArrange() {
try {
await ElMessageBox.confirm(
'系统将为未分配考场的场次自动匹配教室,为未满监考的场次自动分配教师。',
'自动编排', { type: 'info', confirmButtonText: '开始编排' })
arrangeLoading.value = true
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-arrange`)
ElMessage.success(res.data.message)
await selectPlan(selected.value.id)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function publishPlan() {
try {
await ElMessageBox.confirm('发布后考试时间、考场与监考安排将锁定。', '发布补考计划', {
type: 'warning', confirmButtonText: '确认发布',
})
await http.post(`/makeup-exams/plans/${selected.value.id}/publish`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function showRoster(row: any) {
try {
roster.value = (await http.get(`/makeup-exams/sessions/${row.id}/roster`)).data
rosterDrawer.value = true
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
function periodOptions() {
const max = timeSlots.value.length > 0 ? timeSlots.value[timeSlots.value.length - 1].periodNumber : 12
return Array.from({ length: max }, (_, i) => ({ value: i + 1, label: `第 ${i + 1} 节` }))
}
function periodCountOptions() {
return [1, 2, 3, 4].map(n => ({ value: n, label: `${n} 小节` }))
}
function classroomLabel(room: any) {
return `${room.name} · ${room.capacity}座 · ${room.buildingName}`
}
function filteredRooms() {
if (!sessionForm.requiredBuildingId) return rooms.value
return rooms.value.filter((r: any) => r.buildingId === sessionForm.requiredBuildingId)
}
function openEnrollment(session: any) {
enrollmentSession.value = session
eligibleStudents.value = []
selectedStudentIds.value = []
enrollmentDialog.value = true
}
async function loadEligibleStudents() {
if (!enrollmentSession.value) return
eligibleLoading.value = true
try {
eligibleStudents.value = (await http.get('/makeup-exams/eligible-students', {
params: { teachingTaskId: enrollmentSession.value.teachingTaskId },
})).data
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { eligibleLoading.value = false }
}
function onEnrollmentSelection(rows: any[]) {
selectedStudentIds.value = rows.map((r: any) => r.studentId)
}
async function enrollSelectedStudents() {
if (!enrollmentSession.value || selectedStudentIds.value.length === 0) return
try {
await http.post(`/makeup-exams/sessions/${enrollmentSession.value.id}/enroll`, {
studentIds: selectedStudentIds.value,
})
ElMessage.success(`已登记 ${selectedStudentIds.value.length} 名学生`)
enrollmentDialog.value = false
await selectPlan(selected.value.id)
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
}
async function archivePlan() {
try {
await ElMessageBox.confirm('归档后将不再对学生和教师显示。', '归档补考计划', {
type: 'info', confirmButtonText: '确认归档',
})
await http.post(`/makeup-exams/plans/${selected.value.id}/archive`)
await load()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
// ── Score entry (teacher) ──
function openScoreDialog(session: any) {
scoreSession.value = session
Object.keys(scoreMap).forEach(k => delete scoreMap[k])
if (session.enrollments) {
for (const e of session.enrollments) {
scoreMap[e.studentId] = e.makeupScore ?? null
}
}
scoreDialogVisible.value = true
}
function onScoreInput(studentId: string, val: any) {
scoreMap[studentId] = val ?? null
}
async function submitScores() {
if (!scoreSession.value) return
const entries = Object.entries(scoreMap)
.filter(([, v]) => v !== null && v !== undefined)
.map(([studentId, score]) => ({ studentId, score: Number(score) }))
if (entries.length === 0) { ElMessage.warning('没有可保存的成绩'); return }
scoreSaving.value = true
try {
await http.put(`/makeup-exams/sessions/${scoreSession.value.id}/scores`, entries)
ElMessage.success(`已保存 ${entries.length} 条补考成绩(合格按60分记)`)
scoreDialogVisible.value = false
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
finally { scoreSaving.value = false }
}
// ── Auto-create job ──
const autoJobId = ref<string | null>(null)
const autoJobStatus = ref<string | null>(null)
const autoJobProgress = ref(0)
const autoJobMessage = ref('')
let pollTimer: ReturnType<typeof setInterval> | null = null
async function startAutoCreate() {
try {
await ElMessageBox.confirm(
'系统将自动扫描所有已发布教学任务,查询需补考的学生,创建补考场次并登记学生。不同补考科目可混编在同一考场。',
'一键生成补考安排', { type: 'info', confirmButtonText: '开始生成' })
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-create`)
autoJobId.value = res.data.jobId
autoJobStatus.value = 'Queued'
autoJobProgress.value = 0
autoJobMessage.value = '任务已提交,正在排队...'
startPolling()
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(pollAutoJob, 1500)
}
async function pollAutoJob() {
if (!autoJobId.value) return
try {
const res = await http.get(`/makeup-exams/auto-jobs/${autoJobId.value}`)
const job = res.data
autoJobStatus.value = job.status
if (job.totalCourses > 0) {
autoJobProgress.value = Math.round((job.processedCourses / job.totalCourses) * 100)
}
autoJobMessage.value = job.status === 'Succeeded'
? `已完成:${job.createdSessions} 个补考场次,${job.enrolledStudents} 名学生`
: job.status === 'Failed'
? `失败:${job.errorMessage || '未知错误'}`
: `处理中:${job.processedCourses}/${job.totalCourses} 门课程`
if (job.status === 'Succeeded' || job.status === 'Failed') {
stopPolling()
if (job.status === 'Succeeded') {
ElMessage.success(autoJobMessage.value)
await selectPlan(selected.value.id)
} else {
ElMessage.error(autoJobMessage.value)
}
}
} catch (_) { stopPolling() }
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
onMounted(async () => {
try {
if (isManager.value) {
const currentTermId = (await http.get('/base-data/terms')).data.find((t: any) => t.isCurrent)?.id
const [termRes, taskRes, roomRes, teacherRes, buildingRes, slotRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/teaching-tasks', { params: { page: 1, pageSize: 200 } }),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
http.get('/base-data/buildings'),
currentTermId ? http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId: currentTermId } }) : Promise.resolve({ data: [] }),
])
terms.value = termRes.data
tasks.value = taskRes.data.items
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
buildings.value = buildingRes.data
timeSlots.value = slotRes.data
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
</script>
<template>
<div class="page-stack exam-page">
<section class="page-intro">
<div>
<span class="section-kicker">MAKE-UP EXAMINATION</span>
<h2>{{ isManager ? '补考安排' : isTeacher ? '补考监考' : '我的补考' }}</h2>
<p>{{ isManager ? '为不及格、缺考或缓考通过的学生安排补考,登记考生并录入成绩。' : '查看已发布的补考日程。' }}</p>
</div>
<el-button v-if="isManager" type="primary" :icon="Plus" @click="openPlan">新建补考计划</el-button>
<el-button v-else :icon="Refresh" @click="load">刷新日程</el-button>
</section>
<template v-if="isManager">
<section class="exam-plan-strip">
<button v-for="plan in plans" :key="plan.id" :class="{ active: selected?.id === plan.id, 'historical-record': !plan.termIsCurrent && !plan.termIsArchived, 'archived-record': plan.termIsArchived }" @click="selectPlan(plan.id)">
<span>{{ plan.termName }}</span><b>{{ plan.name }}</b>
<small>{{ plan.sessionCount }} 个场次</small><i>{{ statusLabels[plan.status] }}</i>
</button>
</section>
<section v-if="selected" class="exam-board" v-loading="loading">
<header>
<div>
<span>MAKE-UP EXAM TIMELINE</span>
<h3>{{ selected.name }}</h3>
<p>{{ selected.termName }} · {{ selected.sessions.length }} 个补考场次</p>
</div>
<div class="exam-actions">
<el-button v-if="selected.status === 'Draft'" :icon="MagicStick" type="success" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange" :loading="arrangeLoading">自动编排</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" @click="openSession()">安排补考场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
</div>
</header>
<div v-if="autoJobId && autoJobStatus !== 'Succeeded' && autoJobStatus !== 'Failed'" style="margin-bottom: 16px">
<el-alert :title="autoJobMessage" type="info" :closable="false">
<template #default>
<el-progress :percentage="autoJobProgress" :stroke-width="8" />
</template>
</el-alert>
</div>
<div class="exam-timeline">
<article v-for="session in selected.sessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time>
<b>{{ dateOnlyText(session.examDate) }}</b>
<span>{{ periodLabel(session) }}</span>
</time>
<div>
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4>
<p>
<template v-if="session.classroomId">{{ session.buildingName }} · {{ session.classroomName }} · {{ session.classroomCapacity }}</template>
<template v-else><el-tag size="small" type="warning">待分配考场</el-tag></template>
· {{ session.studentCount }}
<template v-if="session.requiredBuildingName"> · {{ session.requiredBuildingName }}</template>
<template v-if="session.requiredInvigilatorCount > 1"> · {{ session.requiredInvigilatorCount }}名监考</template>
</p>
</div>
<div class="exam-staff">
<span>监考</span>
<b>{{ session.invigilatorNames.length ? session.invigilatorNames.join('、') : '待分配' }}</b>
<small>{{ timeText(session.startsAt) }}{{ timeText(session.endsAt) }}</small>
</div>
<div class="exam-row-actions">
<el-button link type="primary" @click="showRoster(session)">考生名单</el-button>
<el-button v-if="selected.status === 'Draft'" link type="success" @click="openEnrollment(session)">登记考生</el-button>
<el-button v-if="selected.status === 'Draft'" link type="primary" @click="openSession(session)">编辑</el-button>
<el-button v-if="selected.status === 'Draft'" link type="danger" @click="removeSession(session)">移除</el-button>
</div>
</article>
<el-empty v-if="!selected.sessions.length" description="尚未安排补考场次,点击「安排补考场次」开始。" />
</div>
</section>
</template>
<!-- Teacher: teaching sessions for score entry -->
<section v-if="isTeacher && teachingSessions.length" class="exam-board" v-loading="loading" style="margin-top: 0">
<header>
<div>
<span>MAKE-UP EXAM GRADING</span>
<h3>任课补考成绩录入</h3>
<p>以下为您任课班级的补考场次补考合格按60分记入最终成绩</p>
</div>
</header>
<div class="exam-timeline">
<article v-for="session in teachingSessions" :key="session.id">
<time>
<b>{{ dateOnlyText(session.examDate) }}</b>
<span>{{ periodLabel(session) }}</span>
</time>
<div>
<span>{{ session.courseCode }} · {{ session.taskNumber }}</span>
<h4>{{ session.courseName }}</h4>
<p>
{{ session.buildingName ? `${session.buildingName} · ${session.classroomName}` : '考场待定' }}
· {{ session.enrolledCount }} · 已录 {{ session.gradedCount }}
</p>
</div>
<div class="exam-row-actions">
<el-button type="warning" @click="openScoreDialog(session)">录入成绩</el-button>
</div>
</article>
</div>
</section>
<section v-else-if="!isTeacher" class="exam-ticket-grid" v-loading="loading">
<article v-for="item in personal" :key="item.id">
<div class="exam-ticket-date">
<b>{{ dateOnlyText(item.examDate) }}</b>
<span>{{ timeText(item.startsAt) }}{{ timeText(item.endsAt) }}</span>
</div>
<div>
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
<p v-if="item.reason" style="margin-top: 4px">
<span>补考原因{{ item.reason === 1 ? '不及格' : item.reason === 2 ? '缺考' : '缓考通过' }}</span>
<span v-if="item.makeupScore != null"> · 成绩<b>{{ item.makeupScore }}</b></span>
</p>
</div>
<footer>
<el-icon><UserFilled /></el-icon>
监考{{ item.invigilatorNames?.join('、') || '待定' }}
</footer>
</article>
<el-empty v-if="!personal.length" description="暂无已发布补考安排" />
</section>
<!-- Teacher invigilation schedule -->
<section v-if="isTeacher && personal.length" class="exam-ticket-grid" v-loading="loading" style="margin-top: 0">
<h3 style="margin-bottom: 16px; font-size: 16px; color: #666">补考监考安排</h3>
<article v-for="item in personal" :key="'inv-' + item.id">
<div class="exam-ticket-date">
<b>{{ dateOnlyText(item.examDate) }}</b>
<span>{{ timeText(item.startsAt) }}{{ timeText(item.endsAt) }}</span>
</div>
<div>
<span>{{ item.courseCode }} · {{ item.taskNumber }}</span>
<h3>{{ item.courseName }}</h3>
<p>{{ item.buildingName ? `${item.buildingName} · ${item.classroomName}` : '考场待定' }}</p>
</div>
<footer>
<el-icon><UserFilled /></el-icon>
{{ item.enrolledCount ?? item.studentCount ?? 0 }} 名考生
</footer>
</article>
</section>
<!-- Plan Dialog -->
<el-dialog v-model="planDialog" title="新建补考计划" width="600px">
<el-form label-position="top">
<el-form-item label="学期">
<el-select v-model="planForm.academicTermId">
<el-option v-for="x in terms" :key="x.id" :label="academicTermLabel(x)" :value="x.id" :class="academicTermOptionClass(x)" />
</el-select>
</el-form-item>
<el-form-item label="计划名称"><el-input v-model="planForm.name" maxlength="120" /></el-form-item>
<el-form-item label="说明"><el-input v-model="planForm.notes" type="textarea" maxlength="500" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="planDialog = false">取消</el-button>
<el-button type="primary" @click="savePlan">保存草稿</el-button>
</template>
</el-dialog>
<!-- Session Dialog -->
<el-dialog v-model="sessionDialog" :title="editingSession ? '编辑补考场次' : '安排补考场次'" width="720px" top="5vh">
<el-form label-position="top">
<el-form-item label="教学班">
<el-select v-model="sessionForm.teachingTaskId" filterable placeholder="选择教学班">
<el-option v-for="x in tasks" :key="x.id" :label="`${x.taskNumber} · ${x.courseName}`" :value="x.id" />
</el-select>
</el-form-item>
<div class="form-grid">
<el-form-item label="考试日期">
<el-date-picker v-model="sessionForm.examDate" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" />
</el-form-item>
<el-form-item label="起始节次">
<el-select v-model="sessionForm.startPeriod">
<el-option v-for="opt in periodOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="持续节数">
<el-select v-model="sessionForm.periodCount">
<el-option v-for="opt in periodCountOptions()" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="教学楼限制">
<el-select v-model="sessionForm.requiredBuildingId" clearable placeholder="不限教学楼">
<el-option v-for="x in buildings" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考人数">
<el-input-number v-model="sessionForm.requiredInvigilatorCount" :min="1" :max="10" />
</el-form-item>
</div>
<el-form-item label="考场(可留空,由自动编排分配)">
<el-select v-model="sessionForm.classroomId" clearable filterable placeholder="留空由自动编排分配">
<el-option v-for="x in filteredRooms()" :key="x.id" :label="classroomLabel(x)" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="监考教师(可留空,由自动编排分配)">
<el-select v-model="sessionForm.invigilatorIds" multiple filterable clearable placeholder="留空由自动编排分配">
<el-option v-for="x in teachers" :key="x.id" :label="`${x.teacherNumber} · ${x.name}`" :value="x.id" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="sessionForm.notes" maxlength="500" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sessionDialog = false">取消</el-button>
<el-button type="primary" @click="saveSession">{{ editingSession ? '保存修改' : '保存场次' }}</el-button>
</template>
</el-dialog>
<!-- Roster Drawer -->
<el-drawer v-model="rosterDrawer" title="考生名单" size="600px">
<el-table v-if="roster" :data="roster.students">
<el-table-column prop="studentNumber" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column prop="className" label="行政班" width="130" />
<el-table-column label="补考原因" width="90">
<template #default="scope">
<span>{{ scope.row.reason === 1 ? '不及格' : scope.row.reason === 2 ? '缺考' : scope.row.reason === 3 ? '缓考通过' : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="成绩" width="70">
<template #default="scope">
{{ scope.row.makeupScore ?? '-' }}
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- Enrollment Dialog -->
<el-dialog v-model="enrollmentDialog" title="登记补考学生" width="700px">
<div v-if="enrollmentSession">
<p><b>{{ enrollmentSession.courseName }}</b> · {{ dateOnlyText(enrollmentSession.examDate) }} {{ periodLabel(enrollmentSession) }}</p>
<p style="margin-bottom: 12px">当前已登记<b>{{ enrollmentSession.enrolledCount ?? enrollmentSession.studentCount }}</b> </p>
<el-button type="primary" :icon="Search" @click="loadEligibleStudents" :loading="eligibleLoading" style="margin-bottom: 12px">查询补考资格</el-button>
<el-table v-if="eligibleStudents.length" :data="eligibleStudents" @selection-change="onEnrollmentSelection" ref="enrollmentTable">
<el-table-column type="selection" width="40" />
<el-table-column prop="studentNumber" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column prop="className" label="行政班" width="140" />
<el-table-column label="补考原因" width="100">
<template #default="scope">
<span>{{ scope.row.reason === 1 ? '不及格' : scope.row.reason === 2 ? '缺考' : '缓考通过' }}</span>
</template>
</el-table-column>
<el-table-column label="详情" min-width="120">
<template #default="scope">
<span v-if="scope.row.reason === 1 && scope.row.totalScore != null">原始成绩 {{ scope.row.totalScore }}</span>
<span v-else-if="scope.row.reason === 3">已批准缓考</span>
<span v-else>缺考</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else-if="!eligibleLoading" description="该教学班暂无需要补考的学生,或所有符合资格的学生均已登记。" />
</div>
<template #footer>
<el-button @click="enrollmentDialog = false">取消</el-button>
<el-button type="primary" @click="enrollSelectedStudents" :disabled="!selectedStudentIds.length">登记所选 {{ selectedStudentIds.length }} 名学生</el-button>
</template>
</el-dialog>
<!-- Score Dialog (teacher) -->
<el-dialog v-model="scoreDialogVisible" title="录入补考成绩" width="680px">
<template v-if="scoreSession">
<p style="margin-bottom: 12px"><b>{{ scoreSession.courseName }}</b> · {{ dateOnlyText(scoreSession.examDate) }} · {{ scoreSession.enrolledCount }} 名考生</p>
<el-alert type="warning" title="补考合格按60分记入最终成绩" :closable="false" style="margin-bottom: 12px" />
<el-table :data="scoreSession.enrollments" size="small">
<el-table-column prop="studentNumber" label="学号" width="120" />
<el-table-column prop="name" label="姓名" width="80" />
<el-table-column label="补考原因" width="90">
<template #default="scope">
<span>{{ scope.row.reason === 1 ? '不及格' : scope.row.reason === 2 ? '缺考' : '缓考通过' }}</span>
</template>
</el-table-column>
<el-table-column label="补考成绩" width="150">
<template #default="scope">
<el-input-number :model-value="scoreMap[scope.row.studentId]" @update:model-value="onScoreInput(scope.row.studentId, $event)" :min="0" :max="100" :precision="1" size="small" controls-position="right" placeholder="0-100" />
</template>
</el-table-column>
</el-table>
</template>
<template #footer>
<el-button @click="scoreDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitScores" :loading="scoreSaving">保存成绩</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.exam-actions {
display: flex;
gap: 8px;
}
.exam-timeline article.unassigned {
border-left-color: #e6a23c;
}
.enrollment-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.enrollment-header p {
margin: 0;
}
</style>