补考增强
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user