Files
Academic-Affairs-System/web/src/views/MakeupExamsView.vue
T
biss 52e4a8a5be 1. 教室容量按 1/2 计算
- ExamArrangementService.cs: SelectRooms() 使用 Capacity / 2 选择房间、计算剩余座位和总容量
  - MakeupExamArrangementService.cs: 数据库查询用 x.Capacity >= enrolledCount * 2(等价于 Capacity/2 >=
  enrolledCount),消息显示有效座位数

  2. 教学楼限制多选

  - Domain: ExamSession 和 MakeupExamSession 新增 RequiredBuildingIds (JSON string),保留旧 RequiredBuildingId 向后兼容
  - Service: RoomGroupKey 改为字符串键确保值相等;GroupKey() 合并新旧字段
  - Controller: 请求 DTO 增加 RequiredBuildingIds (Guid 数组),响应包含该字段
  - DB: MySQL 迁移 + SQLite migrator 添加新列
  - Frontend: <el-select> 改为 multiple,新增 parseBuildingIds() 解析服务器返回的 JSON

  3. 导出签名单后台任务

  - 新增: ExamSignInExportJob 实体、ExamSignInExportJobProcessor、ExamSignInExportJobStatus 枚举
  - BackgroundJobKind: 新增 ExamSignInExport = 5
  - RabbitMQ: routing key exam.sign-in-export,队列 jiaowu.background-jobs.exam.sign-in-export
  - API: POST /sign-in-export 创建任务返回 202;GET /sign-in-exports/{jobId} 查询状态;GET
  /sign-in-exports/{jobId}/download 下载文件
  - Frontend: 导出改为异步任务 + 轮询 + 自动下载,显示进度条
  - 恢复: OutboxPublisher 启动时恢复未完成的任务,重试超限自动标记失败
2026-07-27 22:00:35 +08:00

961 lines
43 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, onBeforeUnmount, 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 arrangementJob = ref<any | null>(null)
let arrangementPollTimer: ReturnType<typeof setTimeout> | null = null
const arrangementJobRunning = computed(() =>
['Queued', 'Running'].includes(arrangementJob.value?.status))
const arrangementProgress = computed(() =>
arrangementJob.value?.status === 'Queued' ? 10
: arrangementJob.value?.status === 'Running' ? 60
: arrangementJob.value?.status === 'Succeeded' ? 100 : 0)
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 selectedTaskIds = ref<string[]>([])
const selectedSessionIds = ref<string[]>([])
const planForm = reactive<Record<string, any>>({})
const sessionForm = reactive<Record<string, any>>({})
const taskFilter = reactive({ keyword: '', collegeId: '', courseNature: '' })
const sessionFilter = reactive({ keyword: '', allocation: '' })
const statusLabels: Record<string, string> = {
Draft: '草稿', Published: '已发布', Archived: '已归档',
}
const courseNatureLabels: Record<string, string> = {
GeneralRequired: '公共必修',
GeneralElective: '公共选修',
MajorRequired: '专业必修',
MajorElective: '专业选修',
Practice: '实践教学',
}
const taskColleges = computed(() => {
const values = new Map<string, string>()
tasks.value.forEach((task: any) => values.set(task.collegeId, task.collegeName))
return Array.from(values, ([id, name]) => ({ id, name }))
})
const filteredTasks = computed(() => {
const keyword = taskFilter.keyword.trim().toLowerCase()
const arrangedIds = new Set((selected.value?.sessions ?? [])
.filter((session: any) => session.id !== editingSession.value?.id)
.map((session: any) => session.teachingTaskId))
return tasks.value.filter((task: any) => {
if (arrangedIds.has(task.id)) return false
if (taskFilter.collegeId && task.collegeId !== taskFilter.collegeId) return false
if (taskFilter.courseNature && task.courseNature !== taskFilter.courseNature) return false
if (!keyword) return true
return [task.taskNumber, task.courseCode, task.courseName, task.name,
...(task.teacherNames ?? []), ...(task.classNames ?? [])]
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
})
})
const filteredSessions = computed(() => {
const keyword = sessionFilter.keyword.trim().toLowerCase()
return (selected.value?.sessions ?? []).filter((session: any) => {
if (sessionFilter.allocation === 'room' && session.classroomId) return false
if (sessionFilter.allocation === 'invigilator' &&
session.invigilatorIds.length >= session.requiredInvigilatorCount) return false
if (sessionFilter.allocation === 'complete' &&
(!session.classroomId || session.invigilatorIds.length < session.requiredInvigilatorCount)) return false
if (!keyword) return true
return [session.taskNumber, session.courseCode, session.courseName, session.taskName]
.some((value: any) => String(value ?? '').toLowerCase().includes(keyword))
})
})
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) {
stopArrangementPolling()
selected.value = (await http.get(`/makeup-exams/plans/${id}`)).data
selectedSessionIds.value = []
await Promise.all([
loadPlanResources(selected.value.academicTermId),
restoreArrangementJob(id),
])
}
async function loadPlanResources(academicTermId: string) {
const [taskRes, slotRes] = await Promise.all([
http.get('/teaching-tasks/options', {
params: { academicTermId, status: 'Published' },
}),
http.get('/makeup-exams/time-slots-for-term', { params: { academicTermId } }),
])
tasks.value = taskRes.data
timeSlots.value = slotRes.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
selectedTaskIds.value = existing ? [existing.teachingTaskId] : []
Object.assign(taskFilter, { keyword: '', collegeId: '', courseNature: '' })
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,
requiredBuildingIds: parseBuildingIds(existing?.requiredBuildingIds),
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 {
if (selectedTaskIds.value.length === 0) {
ElMessage.warning('请至少选择一个教学班')
return
}
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/sessions/batch`, {
teachingTaskIds: selectedTaskIds.value,
examDate: payload.examDate,
startPeriod: payload.startPeriod,
periodCount: payload.periodCount,
requiredBuildingId: payload.requiredBuildingId,
requiredBuildingIds: payload.requiredBuildingIds,
requiredInvigilatorCount: payload.requiredInvigilatorCount,
notes: payload.notes,
})
ElMessage.success(`已批量创建 ${res.data.createdCount} 个补考场次`)
}
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))
}
}
function selectFilteredTasks() {
selectedTaskIds.value = Array.from(new Set([
...selectedTaskIds.value,
...filteredTasks.value.map((task: any) => task.id),
])).slice(0, 100)
}
function clearFilteredTasks() {
const visibleIds = new Set(filteredTasks.value.map((task: any) => task.id))
selectedTaskIds.value = selectedTaskIds.value.filter(id => !visibleIds.has(id))
}
function toggleTaskSelection(id: string, checked: boolean) {
if (checked && selectedTaskIds.value.length >= 100) {
ElMessage.warning('一次最多选择100个教学班')
return
}
selectedTaskIds.value = checked
? Array.from(new Set([...selectedTaskIds.value, id]))
: selectedTaskIds.value.filter(value => value !== id)
}
function selectFilteredSessions() {
selectedSessionIds.value = filteredSessions.value.map((session: any) => session.id)
}
function clearSessionSelection() {
selectedSessionIds.value = []
}
function toggleSessionSelection(id: string, checked: boolean) {
selectedSessionIds.value = checked
? Array.from(new Set([...selectedSessionIds.value, id]))
: selectedSessionIds.value.filter(value => value !== id)
}
async function autoArrange(mode: 'rooms' | 'invigilators' | 'all') {
try {
const target = selectedSessionIds.value.length
? `所选 ${selectedSessionIds.value.length} 个场次`
: '当前计划全部场次'
const action = mode === 'rooms' ? '分配考场'
: mode === 'invigilators' ? '分配监考教师' : '分配考场和监考教师'
await ElMessageBox.confirm(
`系统将为${target}${action},已有安排不会被覆盖。`,
`一键${action}`, { type: 'info', confirmButtonText: '开始分配' })
arrangeLoading.value = true
const res = await http.post(`/makeup-exams/plans/${selected.value.id}/auto-arrange`, {
sessionIds: selectedSessionIds.value,
assignClassrooms: mode !== 'invigilators',
assignInvigilators: mode !== 'rooms',
})
arrangementJob.value = {
id: res.data.jobId,
planId: selected.value.id,
status: res.data.status,
currentStep: '等待后台编排',
}
ElMessage.success(res.data.message)
scheduleArrangementPoll(true)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
} finally { arrangeLoading.value = false }
}
async function restoreArrangementJob(planId: string) {
try {
const res = await http.get(
`/makeup-exams/plans/${planId}/arrangement-job`,
)
if (res.status === 204 || !res.data ||
!['Queued', 'Running'].includes(res.data.status)) {
arrangementJob.value = null
return
}
arrangementJob.value = res.data
scheduleArrangementPoll(false)
} catch {
arrangementJob.value = null
}
}
function scheduleArrangementPoll(notifyTerminal: boolean) {
stopArrangementPolling()
arrangementPollTimer = setTimeout(
() => pollArrangementJob(notifyTerminal),
1500,
)
}
async function pollArrangementJob(notifyTerminal: boolean) {
if (!arrangementJob.value?.id) return
const jobId = arrangementJob.value.id
const planId = selected.value?.id
try {
const { data: job } = await http.get(
`/makeup-exams/arrangement-jobs/${jobId}`,
)
if (selected.value?.id !== planId ||
arrangementJob.value?.id !== jobId) return
arrangementJob.value = job
if (['Queued', 'Running'].includes(job.status)) {
scheduleArrangementPoll(notifyTerminal)
return
}
stopArrangementPolling()
if (job.status === 'Succeeded') {
const planId = selected.value?.id
if (planId) await selectPlan(planId)
if (notifyTerminal) ElMessage.success(job.resultMessage || '补考编排完成')
} else if (notifyTerminal) {
ElMessage.error(job.errorMessage || '补考编排失败')
}
} catch {
if (selected.value?.id === planId &&
arrangementJob.value?.id === jobId) {
scheduleArrangementPoll(notifyTerminal)
}
}
}
function stopArrangementPolling() {
if (arrangementPollTimer) {
clearTimeout(arrangementPollTimer)
arrangementPollTimer = null
}
}
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.requiredBuildingIds || sessionForm.requiredBuildingIds.length === 0) return rooms.value
return rooms.value.filter((r: any) => sessionForm.requiredBuildingIds.includes(r.buildingId))
}
function parseBuildingIds(raw: string | undefined | null): string[] {
if (!raw) return []
try { return JSON.parse(raw) } catch { return [] }
}
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 [termRes, roomRes, teacherRes, buildingRes] = await Promise.all([
http.get('/base-data/terms'),
http.get('/base-data/classrooms'),
http.get('/personnel/teachers', { params: { page: 1, pageSize: 200, teacherStatus: 'Active' } }),
http.get('/base-data/buildings'),
])
terms.value = termRes.data
rooms.value = roomRes.data
teachers.value = teacherRes.data.items
buildings.value = buildingRes.data
}
await load()
} catch (error) { ElMessage.error(apiErrorMessage(error)) }
})
onBeforeUnmount(() => {
stopPolling()
stopArrangementPolling()
})
</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" :disabled="arrangementJobRunning" @click="startAutoCreate">一键生成</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('rooms')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配考场</el-button>
<el-button v-if="selected.status === 'Draft'" @click="autoArrange('invigilators')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键分配监考</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Setting" @click="autoArrange('all')" :loading="arrangeLoading" :disabled="arrangementJobRunning || ['Queued', 'Running'].includes(autoJobStatus ?? '')">一键完成</el-button>
<el-button v-if="selected.status === 'Draft'" :icon="Plus" :disabled="arrangementJobRunning" @click="openSession()">批量安排场次</el-button>
<el-button v-if="selected.status === 'Draft'" type="primary" :icon="Promotion" :disabled="arrangementJobRunning" @click="publishPlan">发布计划</el-button>
<el-button v-if="selected.status === 'Published'" type="info" @click="archivePlan">归档</el-button>
</div>
</header>
<el-alert
v-if="arrangementJob"
:title="arrangementJob.status === 'Failed' ? '后台编排失败' : arrangementJob.status === 'Succeeded' ? '后台编排完成' : '后台正在编排补考'"
:type="arrangementJob.status === 'Failed' ? 'error' : arrangementJob.status === 'Succeeded' ? 'success' : 'info'"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<p>{{ arrangementJob.errorMessage || arrangementJob.resultMessage || arrangementJob.currentStep || '等待后台任务处理' }}</p>
<el-progress
v-if="arrangementJobRunning"
:percentage="arrangementProgress"
:show-text="false"
:stroke-width="6"
:indeterminate="arrangementJob.status === 'Running'"
/>
</template>
</el-alert>
<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-filter-bar">
<el-input v-model="sessionFilter.keyword" clearable placeholder="筛选课程、课程号或教学班号" />
<el-select v-model="sessionFilter.allocation" clearable placeholder="全部分配状态">
<el-option label="待分配考场" value="room" />
<el-option label="待补足监考" value="invigilator" />
<el-option label="已完成分配" value="complete" />
</el-select>
<span>显示 {{ filteredSessions.length }}/{{ selected.sessions.length }} 个场次</span>
<template v-if="selected.status === 'Draft'">
<el-button link type="primary" @click="selectFilteredSessions">选择筛选结果</el-button>
<el-button link @click="clearSessionSelection">清空选择</el-button>
<el-tag v-if="selectedSessionIds.length" type="info">已选 {{ selectedSessionIds.length }} </el-tag>
</template>
</div>
<div class="exam-timeline">
<article v-for="session in filteredSessions" :key="session.id" :class="{ unassigned: !session.classroomId }">
<time>
<el-checkbox
v-if="selected.status === 'Draft'"
:model-value="selectedSessionIds.includes(session.id)"
@change="toggleSessionSelection(session.id, Boolean($event))"
>选择</el-checkbox>
<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.enrolledCount ?? session.studentCount ?? 0 }}
<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="尚未安排补考场次,点击「批量安排场次」开始。" />
<el-empty v-else-if="!filteredSessions.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>
<el-tag v-if="item.seatNumber" size="small" type="success">座位号 {{ item.seatNumber }}</el-tag>
<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="960px" top="3vh">
<el-form label-position="top">
<el-form-item v-if="editingSession" 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>
<template v-else>
<div class="task-filter-grid">
<el-input v-model="taskFilter.keyword" clearable placeholder="课程、教学班、教师或行政班" />
<el-select v-model="taskFilter.collegeId" clearable placeholder="全部开课单位">
<el-option v-for="x in taskColleges" :key="x.id" :label="x.name" :value="x.id" />
</el-select>
<el-select v-model="taskFilter.courseNature" clearable placeholder="全部课程性质">
<el-option v-for="(label, value) in courseNatureLabels" :key="value" :label="label" :value="value" />
</el-select>
</div>
<div class="task-selection-actions">
<span>可选 {{ filteredTasks.length }} 已选 {{ selectedTaskIds.length }} </span>
<el-button link type="primary" @click="selectFilteredTasks">选择全部筛选结果</el-button>
<el-button link @click="clearFilteredTasks">清除筛选结果选择</el-button>
</div>
<el-table :data="filteredTasks" height="260" size="small">
<el-table-column label="选择" width="64">
<template #default="scope">
<el-checkbox
:model-value="selectedTaskIds.includes(scope.row.id)"
@click.stop
@update:model-value="toggleTaskSelection(scope.row.id, Boolean($event))"
/>
</template>
</el-table-column>
<el-table-column prop="taskNumber" label="教学班号" width="130" />
<el-table-column prop="courseName" label="课程" min-width="150" />
<el-table-column prop="collegeName" label="开课单位" min-width="130" />
<el-table-column label="任课教师" min-width="120">
<template #default="scope">{{ scope.row.teacherNames?.join('、') || '待定' }}</template>
</el-table-column>
<el-table-column label="行政班" min-width="150">
<template #default="scope">{{ scope.row.classNames?.join('、') || '-' }}</template>
</el-table-column>
</el-table>
</template>
<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.requiredBuildingIds" multiple 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 v-if="editingSession" 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 v-if="editingSession" 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 ? '保存修改' : `批量创建 ${selectedTaskIds.length} 个场次` }}
</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="seatNumber" label="座位号" width="80" />
<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;
flex-wrap: wrap;
justify-content: flex-end;
}
.exam-filter-bar,
.task-filter-grid,
.task-selection-actions {
display: flex;
align-items: center;
gap: 10px;
}
.exam-filter-bar {
margin-bottom: 16px;
flex-wrap: wrap;
}
.exam-filter-bar .el-input {
width: min(320px, 100%);
}
.exam-filter-bar .el-select {
width: 180px;
}
.task-filter-grid {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) minmax(160px, 1fr) minmax(160px, 1fr);
margin-bottom: 10px;
}
.task-selection-actions {
justify-content: flex-end;
margin-bottom: 8px;
}
.task-selection-actions span {
margin-right: auto;
}
.exam-timeline article.unassigned {
border-left-color: #e6a23c;
}
.enrollment-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.enrollment-header p {
margin: 0;
}
@media (max-width: 720px) {
.exam-actions {
justify-content: flex-start;
}
.task-filter-grid {
grid-template-columns: 1fr;
}
.task-selection-actions {
align-items: flex-start;
flex-direction: column;
}
}
</style>