补考增强

This commit is contained in:
2026-07-25 19:38:39 +08:00 Unverified
parent 44e90ae35f
commit 1da6543a38
7 changed files with 714 additions and 10 deletions
+183 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Promotion, Refresh, UserFilled, Setting, Search } from '@element-plus/icons-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'
@@ -11,6 +11,11 @@ const isTeacher = computed(() => auth.user?.roles.includes('Teacher') && !isMana
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[]>([])
@@ -55,6 +60,9 @@ async function load() {
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
@@ -205,6 +213,94 @@ async function archivePlan() {
}
}
// ── 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) {
@@ -256,12 +352,20 @@ onMounted(async () => {
<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>
@@ -296,7 +400,37 @@ onMounted(async () => {
</section>
</template>
<section v-else class="exam-ticket-grid" v-loading="loading">
<!-- 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>
@@ -313,12 +447,32 @@ onMounted(async () => {
</div>
<footer>
<el-icon><UserFilled /></el-icon>
{{ isTeacher ? `${item.enrolledCount ?? item.studentCount ?? 0} 名考生` : `监考:${item.invigilatorNames?.join('、') || '待定'}` }}
监考{{ 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">
@@ -439,6 +593,32 @@ onMounted(async () => {
<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>