- 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 启动时恢复未完成的任务,重试超限自动标记失败
961 lines
43 KiB
Vue
961 lines
43 KiB
Vue
<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>
|